From ef5e909fd00799674dcdf18d9184354ad02ca194 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Mon, 9 Jan 2023 11:11:43 +0000 Subject: [PATCH 001/170] test --- stonesoup/types/architecture.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 stonesoup/types/architecture.py diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py new file mode 100644 index 000000000..16c1e91b2 --- /dev/null +++ b/stonesoup/types/architecture.py @@ -0,0 +1 @@ +# stuff goes here \ No newline at end of file From e448647143a5008de5d91c4e19533808a8d84ec0 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Mon, 9 Jan 2023 13:12:46 +0000 Subject: [PATCH 002/170] building blocks --- stonesoup/types/architecture.py | 66 +++++++++++++++++++++- stonesoup/types/tests/test_architecture.py | 9 +++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 stonesoup/types/tests/test_architecture.py diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 16c1e91b2..29046d72c 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -1 +1,65 @@ -# stuff goes here \ No newline at end of file +from ..base import Property +from .base import Type +from ..sensor.sensor import Sensor + +from typing import Set, List, Collection, Union +import networkx as nx +import plotly.graph_objects as go + + +class ProcessingNode(Type): + """A node that does not measure new data, but does process data it receives""" + # Latency property could go here + + +class RepeaterNode(Type): + """A node which simply passes data along to others, without manipulating the data itself. """ + # Latency property could go here + + +class Architecture(Type): + node_set: Set[Union[Sensor, ProcessingNode, RepeaterNode]] = Property( + default=None, + doc="A Set of all nodes involved, each of which may be a sensor, processing node, " + "or repeater node. ") + edge_list: Collection = Property( + default=None, + doc="A Collection of edges between nodes. For A to be connected to B we would have (A, B)" + "be a member of this list. ") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.node_set: + self.node_set = set() + if isinstance(self.edge_list, Collection) and not isinstance(self.edge_list, List): + self.edge_list = list(self.edge_list) + + def plot(self): + return + + +class InformationArchitecture(Architecture): + """The architecture for how information is shared through the network. Node A is " + "connected to Node B if and only if the information A creates by processing and/or " + "sensing is received and opened by B without modification by another node. """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for node in self.node_set: + if isinstance(node, RepeaterNode): + raise TypeError("Information architecture should not contain any repeater nodes") + + +class NetworkArchitecture(Architecture): + """The architecture for how data is propagated through the network. Node A is connected " + "to Node B if and only if A sends its data through B. """ + + +class CombinedArchitecture(Type): + """Contains an information and a network architecture that pertain to the same scenario. """ + information_architecture: InformationArchitecture = Property( + doc="The information architecture for how information is shared. ") + network_architecture: NetworkArchitecture = Property( + doc="The architecture for how data is propagated through the network. Node A is connected " + "to Node B if and only if A sends its data through B. " + ) \ No newline at end of file diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py new file mode 100644 index 000000000..66a011e18 --- /dev/null +++ b/stonesoup/types/tests/test_architecture.py @@ -0,0 +1,9 @@ +from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ + CombinedArchitecture, ProcessingNode, RepeaterNode + +import pytest + + +def test_information_architecture(): + with pytest.raises(TypeError): + InformationArchitecture(node_set={RepeaterNode()}) From fa1d881e100f62369e3ccb57d898bdf2d92be836 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 10 Jan 2023 10:55:45 +0000 Subject: [PATCH 003/170] add architecture density and test for density function --- stonesoup/types/architecture.py | 9 +++++++++ stonesoup/types/tests/test_architecture.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 29046d72c..33f3133f5 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -35,8 +35,17 @@ def __init__(self, *args, **kwargs): self.edge_list = list(self.edge_list) def plot(self): + return + @property + def density(self): + num_nodes = len(self.node_set) + num_edges = len(self.edge_list) + architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) + return architecture_density + + class InformationArchitecture(Architecture): """The architecture for how information is shared through the network. Node A is " diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 66a011e18..043f5e44e 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -7,3 +7,8 @@ def test_information_architecture(): with pytest.raises(TypeError): InformationArchitecture(node_set={RepeaterNode()}) + +def test_density(): + a, b, c, d = RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode() + edge_list = [(0, 1), (1, 2)] + assert Architecture(node_set={a, b, c, d}, edge_list=edge_list).density == 1/3 \ No newline at end of file From 54e1468abd97f38802fb353968353cec188c8dc4 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 10 Jan 2023 15:45:43 +0000 Subject: [PATCH 004/170] Change structure, expand Architecture class --- stonesoup/types/architecture.py | 65 +++++++++++++++++++--- stonesoup/types/tests/test_architecture.py | 34 ++++++++++- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 33f3133f5..2199adfb3 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -2,11 +2,21 @@ from .base import Type from ..sensor.sensor import Sensor -from typing import Set, List, Collection, Union +from typing import Set, List, Collection import networkx as nx import plotly.graph_objects as go +class Node(Type): + """Base node class""" + + +class SensorNode(Node): + """A node corresponding to a Sensor. Fresh data is created here, + and possibly processed as well""" + sensor: Sensor = Property(doc="Sensor corresponding to this node") + + class ProcessingNode(Type): """A node that does not measure new data, but does process data it receives""" # Latency property could go here @@ -18,21 +28,40 @@ class RepeaterNode(Type): class Architecture(Type): - node_set: Set[Union[Sensor, ProcessingNode, RepeaterNode]] = Property( - default=None, - doc="A Set of all nodes involved, each of which may be a sensor, processing node, " - "or repeater node. ") edge_list: Collection = Property( default=None, doc="A Collection of edges between nodes. For A to be connected to B we would have (A, B)" - "be a member of this list. ") + "be a member of this list. Default is None") + node_set: Set[Node] = Property( + default=None, + doc="A Set of all nodes involved, each of which may be a sensor, processing node, " + "or repeater node. If provided, used to check all Nodes given are included " + "in edges of the graph. Default is None") + force_connected: bool = Property( + default=True, + doc="If True, the undirected version of the graph must be connected, ie. all nodes should " + "be connected via some path. Set this to False to allow an unconnected architecture. " + "Default is True" + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not self.node_set: - self.node_set = set() if isinstance(self.edge_list, Collection) and not isinstance(self.edge_list, List): self.edge_list = list(self.edge_list) + if self.edge_list and len(self.edge_list) > 0: + self.di_graph = nx.to_networkx_graph(self.edge_list, create_using=nx.DiGraph) + if self.force_connected and not self.is_connected: + raise ValueError("The graph is not connected. Use force_connected=False, " + "if you wish to override this requirement") + else: + if self.node_set: + raise TypeError("Edge list must be provided, if a node set is. ") + self.di_graph = nx.DiGraph() + if self.node_set: + if not set(self.di_graph.nodes) == self.node_set: + raise ValueError("Provided node set does not match nodes on graph") + else: + self.node_set = set(self.di_graph.nodes) def plot(self): @@ -40,11 +69,31 @@ def plot(self): @property def density(self): + """Returns the density of the graph, ie. the proportion of possible edges between nodes + that exist in the graph""" num_nodes = len(self.node_set) num_edges = len(self.edge_list) architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) return architecture_density + @property + def is_hierarchical(self): + """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" + if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: + return False + else: + return True + + @property + def is_connected(self): + return nx.is_connected(self.to_undirected) + + @property + def to_undirected(self): + return self.di_graph.to_undirected() + + def __len__(self): + return len(self.di_graph) class InformationArchitecture(Architecture): diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 043f5e44e..2ff5f4fa6 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -1,14 +1,42 @@ from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ CombinedArchitecture, ProcessingNode, RepeaterNode +import networkx as nx import pytest +def test_architecture(): + a, b, c, d, e = RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode() + edge_list_fail = [(a, b), (b, d)] + with pytest.raises(ValueError): + # Should fail, as the node set does not match the nodes used in the edge list + Architecture(edge_list=edge_list_fail, node_set={a, b, c, d}, force_connected=False) + edge_list_unconnected = [(a, b), (c, d)] + with pytest.raises(ValueError): + Architecture(edge_list=edge_list_unconnected, node_set={a, b, c, d}, force_connected=True) + a_test = Architecture(edge_list=edge_list_unconnected, + node_set={a, b, c, d}, + force_connected=False) + edge_list_connected = [(a, b), (b, c)] + a_test_hier = Architecture(edge_list=edge_list_connected, + force_connected=False) + edge_list_loop = [(a, b), (b, c), (c, a)] + a_test_loop = Architecture(edge_list=edge_list_loop, + force_connected=False) + + assert a_test_loop.is_connected and a_test_hier.is_connected + assert a_test_hier.is_hierarchical + assert not a_test_loop.is_hierarchical + + def test_information_architecture(): with pytest.raises(TypeError): - InformationArchitecture(node_set={RepeaterNode()}) + # Repeater nodes have no place in an information architecture + InformationArchitecture(edge_list=[(RepeaterNode(), RepeaterNode())]) + ia_test = InformationArchitecture() + def test_density(): a, b, c, d = RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode() - edge_list = [(0, 1), (1, 2)] - assert Architecture(node_set={a, b, c, d}, edge_list=edge_list).density == 1/3 \ No newline at end of file + edge_list = [(a, b), (c, d), (d, a)] + assert Architecture(edge_list=edge_list, node_set={a, b, c, d}).density == 1/2 From c30fa92144362a0f24fdea5f5cdf4624c20d9aff Mon Sep 17 00:00:00 2001 From: spike Date: Fri, 13 Jan 2023 12:42:45 +0000 Subject: [PATCH 005/170] add progress on plot function in architecture class --- stonesoup/types/architecture.py | 109 ++++++++++++++++++++- stonesoup/types/tests/test_architecture.py | 2 + 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 2199adfb3..96cf8885f 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -2,13 +2,25 @@ from .base import Type from ..sensor.sensor import Sensor -from typing import Set, List, Collection +from typing import Set, List, Collection, Tuple import networkx as nx import plotly.graph_objects as go class Node(Type): """Base node class""" + position: Tuple[float] = Property( + default=None, + doc="Cartesian coordinates for node") + label: str = Property( + default=None, + doc="Label to be displayed on graph") + colour: str = Property( + default=None, + doc = 'Colour to be displayed on graph') + shape: str = Property( + default=None, + doc='Shape used to display nodes') class SensorNode(Node): @@ -16,16 +28,34 @@ class SensorNode(Node): and possibly processed as well""" sensor: Sensor = Property(doc="Sensor corresponding to this node") + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = '#1f77b4' + if not self.shape: + self.shape = 'square' + class ProcessingNode(Type): """A node that does not measure new data, but does process data it receives""" # Latency property could go here + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = '#1f77b4' + if not self.shape: + self.shape = 'square' class RepeaterNode(Type): """A node which simply passes data along to others, without manipulating the data itself. """ # Latency property could go here - + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = '#ff7f0e' + if not self.shape: + self.shape = 'circle' class Architecture(Type): edge_list: Collection = Property( @@ -63,9 +93,78 @@ def __init__(self, *args, **kwargs): else: self.node_set = set(self.di_graph.nodes) - def plot(self): - - return + def plot(self, use_positions=True, label_nodes=False): + """Creates a plot of the directed graph""" + edge_x = [] + edge_y = [] + for edge in self.edge_list: + if use_positions: + x0, y0 = edge[0].position + x1, y1 = edge[1].position + else: + x0, y0 = edge[0].position + x1, y1 = edge[1].position # Add if statement to display as hierarchical if hierarchical + edge_x.append(x0) + edge_x.append(x1) + edge_x.append(None) + edge_y.append(y0) + edge_y.append(y1) + edge_y.append(None) + + edge_trace = go.Scatter( + x=edge_x, y=edge_y, + line=dict(width=0.5, color='#888'), + hoverinfo='none', + mode='lines') + + node_x = [] + node_y = [] + for node in self.node_set: + node_x.append(node.position[0]) + node_y.append(node.position[1]) + + mode = 'markers+text' if label_nodes else 'markers' + marker_shape = self. + node_trace = go.Scatter( + x=node_x, y=node_y, + mode= mode, + hoverinfo='text', + marker=dict( + showscale=True, + # colorscale options + # 'Greys' | 'YlGnBu' | 'Greens' | 'YlOrRd' | 'Bluered' | 'RdBu' | + # 'Reds' | 'Blues' | 'Picnic' | 'Rainbow' | 'Portland' | 'Jet' | + # 'Hot' | 'Blackbody' | 'Earth' | 'Electric' | 'Viridis' | + colorscale='YlGnBu', + reversescale=True, + color=[], + size=50, + colorbar=dict( + thickness=15, + title='Node Connections', + xanchor='left', + titleside='right' + ), + line_width=2)) + + node_trace.marker.color = + node_trace.text = node_text + fig = go.Figure(data=[edge_trace, node_trace], + layout=go.Layout( + title='
Network graph made with Python', + titlefont_size=16, + showlegend=False, + hovermode='closest', + margin=dict(b=20, l=5, r=5, t=40), + annotations=[dict( + text="Python code: https://plotly.com/ipython-notebooks/network-graphs/", + showarrow=False, + xref="paper", yref="paper", + x=0.005, y=-0.002)], + xaxis=dict(showgrid=False, zeroline=False, showticklabels=False), + yaxis=dict(showgrid=False, zeroline=False, showticklabels=False)) + ) + fig.show() @property def density(self): diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 2ff5f4fa6..772940b8f 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -40,3 +40,5 @@ def test_density(): a, b, c, d = RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode() edge_list = [(a, b), (c, d), (d, a)] assert Architecture(edge_list=edge_list, node_set={a, b, c, d}).density == 1/2 + + From 506c1ab07aba320b2a77e9649d1acbdd3b53dc1a Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Thu, 19 Jan 2023 19:11:43 +0000 Subject: [PATCH 006/170] Create basic functionality for Architecture.plot method --- stonesoup/types/architecture.py | 157 +++++++-------------- stonesoup/types/tests/test_architecture.py | 20 ++- 2 files changed, 62 insertions(+), 115 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 96cf8885f..062c7da87 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -4,20 +4,19 @@ from typing import Set, List, Collection, Tuple import networkx as nx -import plotly.graph_objects as go +import graphviz class Node(Type): """Base node class""" + label: str = Property( + doc="Label to be displayed on graph") position: Tuple[float] = Property( default=None, doc="Cartesian coordinates for node") - label: str = Property( - default=None, - doc="Label to be displayed on graph") colour: str = Property( default=None, - doc = 'Colour to be displayed on graph') + doc='Colour to be displayed on graph') shape: str = Property( default=None, doc='Shape used to display nodes') @@ -36,7 +35,7 @@ def __init__(self, *args, **kwargs): self.shape = 'square' -class ProcessingNode(Type): +class ProcessingNode(Node): """A node that does not measure new data, but does process data it receives""" # Latency property could go here def __init__(self, *args, **kwargs): @@ -47,7 +46,7 @@ def __init__(self, *args, **kwargs): self.shape = 'square' -class RepeaterNode(Type): +class RepeaterNode(Node): """A node which simply passes data along to others, without manipulating the data itself. """ # Latency property could go here def __init__(self, *args, **kwargs): @@ -57,114 +56,67 @@ def __init__(self, *args, **kwargs): if not self.shape: self.shape = 'circle' + class Architecture(Type): edge_list: Collection = Property( default=None, doc="A Collection of edges between nodes. For A to be connected to B we would have (A, B)" "be a member of this list. Default is None") - node_set: Set[Node] = Property( - default=None, - doc="A Set of all nodes involved, each of which may be a sensor, processing node, " - "or repeater node. If provided, used to check all Nodes given are included " - "in edges of the graph. Default is None") + name: str = Property( + default=f"Architecture", + doc="A name for the architecture, to be used to name files and/or title plots. Default is " + "\"Architecture\"") force_connected: bool = Property( default=True, doc="If True, the undirected version of the graph must be connected, ie. all nodes should " "be connected via some path. Set this to False to allow an unconnected architecture. " - "Default is True" - ) + "Default is True") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if isinstance(self.edge_list, Collection) and not isinstance(self.edge_list, List): self.edge_list = list(self.edge_list) - if self.edge_list and len(self.edge_list) > 0: - self.di_graph = nx.to_networkx_graph(self.edge_list, create_using=nx.DiGraph) - if self.force_connected and not self.is_connected: - raise ValueError("The graph is not connected. Use force_connected=False, " - "if you wish to override this requirement") - else: - if self.node_set: - raise TypeError("Edge list must be provided, if a node set is. ") - self.di_graph = nx.DiGraph() - if self.node_set: - if not set(self.di_graph.nodes) == self.node_set: - raise ValueError("Provided node set does not match nodes on graph") - else: - self.node_set = set(self.di_graph.nodes) - - def plot(self, use_positions=True, label_nodes=False): - """Creates a plot of the directed graph""" - edge_x = [] - edge_y = [] - for edge in self.edge_list: - if use_positions: - x0, y0 = edge[0].position - x1, y1 = edge[1].position - else: - x0, y0 = edge[0].position - x1, y1 = edge[1].position # Add if statement to display as hierarchical if hierarchical - edge_x.append(x0) - edge_x.append(x1) - edge_x.append(None) - edge_y.append(y0) - edge_y.append(y1) - edge_y.append(None) - - edge_trace = go.Scatter( - x=edge_x, y=edge_y, - line=dict(width=0.5, color='#888'), - hoverinfo='none', - mode='lines') - - node_x = [] - node_y = [] - for node in self.node_set: - node_x.append(node.position[0]) - node_y.append(node.position[1]) - - mode = 'markers+text' if label_nodes else 'markers' - marker_shape = self. - node_trace = go.Scatter( - x=node_x, y=node_y, - mode= mode, - hoverinfo='text', - marker=dict( - showscale=True, - # colorscale options - # 'Greys' | 'YlGnBu' | 'Greens' | 'YlOrRd' | 'Bluered' | 'RdBu' | - # 'Reds' | 'Blues' | 'Picnic' | 'Rainbow' | 'Portland' | 'Jet' | - # 'Hot' | 'Blackbody' | 'Earth' | 'Electric' | 'Viridis' | - colorscale='YlGnBu', - reversescale=True, - color=[], - size=50, - colorbar=dict( - thickness=15, - title='Node Connections', - xanchor='left', - titleside='right' - ), - line_width=2)) - - node_trace.marker.color = - node_trace.text = node_text - fig = go.Figure(data=[edge_trace, node_trace], - layout=go.Layout( - title='
Network graph made with Python', - titlefont_size=16, - showlegend=False, - hovermode='closest', - margin=dict(b=20, l=5, r=5, t=40), - annotations=[dict( - text="Python code: https://plotly.com/ipython-notebooks/network-graphs/", - showarrow=False, - xref="paper", yref="paper", - x=0.005, y=-0.002)], - xaxis=dict(showgrid=False, zeroline=False, showticklabels=False), - yaxis=dict(showgrid=False, zeroline=False, showticklabels=False)) - ) - fig.show() + if not self.edge_list: + self.edge_list = [] + + self.di_graph = nx.to_networkx_graph(self.edge_list, create_using=nx.DiGraph) + + if self.force_connected and not self.is_connected and len(self) > 0: + raise ValueError("The graph is not connected. Use force_connected=False, " + "if you wish to override this requirement") + + # Set attributes such as label, colour, shape, etc for each node + for node in self.di_graph.nodes: + attr = {"label": f"{node.label}", "color": f"{node.colour}"} # add more here + self.di_graph.nodes[node].update(attr) + + @property + def node_set(self): + return set(self.di_graph.nodes) + + def plot(self, dir_path, filename=None, use_positions=True, plot_title=False): + """Creates a pdf plot of the directed graph and displays it + + :param dir_path: The path to save the pdf and .gv files to + :param filename: Name to call the associated files + :param use_positions: + :param plot_title: If a string is supplied, makes this the title of the plot. If True, uses + the name attribute of the graph to title the plot. If False, no title is used. + Default is False + :return: + """ + dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() + if plot_title: + if plot_title is True: + title = self.name + elif not isinstance(plot_title, str): + raise ValueError("Plot title must be a string, or True") + dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{title}\";" + "}" + # print(dot) + if not filename: + filename = self.name + viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path) + viz_graph.view() @property def density(self): @@ -218,5 +170,4 @@ class CombinedArchitecture(Type): doc="The information architecture for how information is shared. ") network_architecture: NetworkArchitecture = Property( doc="The architecture for how data is propagated through the network. Node A is connected " - "to Node B if and only if A sends its data through B. " - ) \ No newline at end of file + "to Node B if and only if A sends its data through B. ") diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 772940b8f..dd8e8ec36 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -1,25 +1,19 @@ from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ CombinedArchitecture, ProcessingNode, RepeaterNode -import networkx as nx import pytest def test_architecture(): - a, b, c, d, e = RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode() - edge_list_fail = [(a, b), (b, d)] - with pytest.raises(ValueError): - # Should fail, as the node set does not match the nodes used in the edge list - Architecture(edge_list=edge_list_fail, node_set={a, b, c, d}, force_connected=False) + a, b, c, d, e = RepeaterNode("a"), RepeaterNode("b"), RepeaterNode("c"), RepeaterNode("d"), \ + RepeaterNode("e") + edge_list_unconnected = [(a, b), (c, d)] with pytest.raises(ValueError): - Architecture(edge_list=edge_list_unconnected, node_set={a, b, c, d}, force_connected=True) - a_test = Architecture(edge_list=edge_list_unconnected, - node_set={a, b, c, d}, - force_connected=False) - edge_list_connected = [(a, b), (b, c)] + Architecture(edge_list=edge_list_unconnected, force_connected=True) + edge_list_connected = [(a, b), (b, c), (b, d)] a_test_hier = Architecture(edge_list=edge_list_connected, - force_connected=False) + force_connected=False, name="bleh") edge_list_loop = [(a, b), (b, c), (c, a)] a_test_loop = Architecture(edge_list=edge_list_loop, force_connected=False) @@ -28,6 +22,8 @@ def test_architecture(): assert a_test_hier.is_hierarchical assert not a_test_loop.is_hierarchical + a_test_hier.plot(dir_path='U:\\My Documents\\temp') + def test_information_architecture(): with pytest.raises(TypeError): From c6eb67811d8fa00a04975f4cf4d250fd27acae67 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 30 Jan 2023 10:28:31 +0000 Subject: [PATCH 007/170] Add shape colour functionality --- stonesoup/types/architecture.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 062c7da87..925865ee8 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -41,9 +41,9 @@ class ProcessingNode(Node): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.colour: - self.colour = '#1f77b4' + self.colour = '#006400' if not self.shape: - self.shape = 'square' + self.shape = 'triangle' class RepeaterNode(Node): @@ -87,7 +87,7 @@ def __init__(self, *args, **kwargs): # Set attributes such as label, colour, shape, etc for each node for node in self.di_graph.nodes: - attr = {"label": f"{node.label}", "color": f"{node.colour}"} # add more here + attr = {"label": f"{node.label}", "color": f"{node.colour}", "shape": f"{node.shape}"} # add more here self.di_graph.nodes[node].update(attr) @property From 82db2bdefa361285f2a7e754b88f58d9dc3d64f0 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 30 Jan 2023 14:51:08 +0000 Subject: [PATCH 008/170] Node title font size and kind of working node labelling system --- stonesoup/types/architecture.py | 34 ++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 925865ee8..913f01be3 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -5,12 +5,13 @@ from typing import Set, List, Collection, Tuple import networkx as nx import graphviz - +from string import ascii_uppercase as auc class Node(Type): """Base node class""" label: str = Property( - doc="Label to be displayed on graph") + doc="Label to be displayed on graph", + default="") position: Tuple[float] = Property( default=None, doc="Cartesian coordinates for node") @@ -20,6 +21,9 @@ class Node(Type): shape: str = Property( default=None, doc='Shape used to display nodes') + fontsize: int = Property( + default=8, + doc='Fontsize for node labels') class SensorNode(Node): @@ -33,6 +37,8 @@ def __init__(self, *args, **kwargs): self.colour = '#1f77b4' if not self.shape: self.shape = 'square' + if not self.fontsize: + self.fontsize = 8 class ProcessingNode(Node): @@ -44,6 +50,8 @@ def __init__(self, *args, **kwargs): self.colour = '#006400' if not self.shape: self.shape = 'triangle' + if not self.fontsize: + self.fontsize = 8 class RepeaterNode(Node): @@ -55,6 +63,8 @@ def __init__(self, *args, **kwargs): self.colour = '#ff7f0e' if not self.shape: self.shape = 'circle' + if not self.fontsize: + self.fontsize = 8 class Architecture(Type): @@ -86,10 +96,28 @@ def __init__(self, *args, **kwargs): "if you wish to override this requirement") # Set attributes such as label, colour, shape, etc for each node + last_letters = {'SensorNode': 'A', 'ProcessingNode': 'A', 'RepeaterNode': 'A'} for node in self.di_graph.nodes: - attr = {"label": f"{node.label}", "color": f"{node.colour}", "shape": f"{node.shape}"} # add more here + if node.label: + label = node.label + else: + label, last_letters = self._default_name(node, last_letters) + + label = node.label if node.label else self._default_name(node, last_letters) + attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", "fontsize": f"{node.fontsize}"} # add more here self.di_graph.nodes[node].update(attr) + def __len__(self): + return len(self.di_graph) + + def _default_name(self, node, last_letters): + node_type = str(type(node)).split('.')[-1][:-2] + last_letter = last_letters[node_type] + current_letter = auc[auc.index(last_letter) + 1] + last_letters[node_type] = current_letter + return node_type + ' ' + current_letter, last_letters + + @property def node_set(self): return set(self.di_graph.nodes) From 4c11c41194dbdf5888339d0e0f9675905fbf09de Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Mon, 30 Jan 2023 18:29:29 +0000 Subject: [PATCH 009/170] Architecture.plot expansion. Allows specification of position, node style, background colour --- stonesoup/types/architecture.py | 95 ++++++++++++++-------- stonesoup/types/tests/test_architecture.py | 19 ++++- 2 files changed, 78 insertions(+), 36 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 913f01be3..f86cad483 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -2,16 +2,17 @@ from .base import Type from ..sensor.sensor import Sensor -from typing import Set, List, Collection, Tuple +from typing import List, Collection, Tuple import networkx as nx import graphviz from string import ascii_uppercase as auc + class Node(Type): """Base node class""" label: str = Property( doc="Label to be displayed on graph", - default="") + default=None) position: Tuple[float] = Property( default=None, doc="Cartesian coordinates for node") @@ -21,9 +22,6 @@ class Node(Type): shape: str = Property( default=None, doc='Shape used to display nodes') - fontsize: int = Property( - default=8, - doc='Fontsize for node labels') class SensorNode(Node): @@ -37,8 +35,8 @@ def __init__(self, *args, **kwargs): self.colour = '#1f77b4' if not self.shape: self.shape = 'square' - if not self.fontsize: - self.fontsize = 8 + # We'd only need to set font_size to something here + # if we wanted a different default for each class class ProcessingNode(Node): @@ -50,8 +48,6 @@ def __init__(self, *args, **kwargs): self.colour = '#006400' if not self.shape: self.shape = 'triangle' - if not self.fontsize: - self.fontsize = 8 class RepeaterNode(Node): @@ -63,8 +59,6 @@ def __init__(self, *args, **kwargs): self.colour = '#ff7f0e' if not self.shape: self.shape = 'circle' - if not self.fontsize: - self.fontsize = 8 class Architecture(Type): @@ -73,14 +67,17 @@ class Architecture(Type): doc="A Collection of edges between nodes. For A to be connected to B we would have (A, B)" "be a member of this list. Default is None") name: str = Property( - default=f"Architecture", + default=None, doc="A name for the architecture, to be used to name files and/or title plots. Default is " - "\"Architecture\"") + "the class name") force_connected: bool = Property( default=True, doc="If True, the undirected version of the graph must be connected, ie. all nodes should " "be connected via some path. Set this to False to allow an unconnected architecture. " "Default is True") + font_size: int = Property( + default=8, + doc='Font size for node labels') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -88,6 +85,8 @@ def __init__(self, *args, **kwargs): self.edge_list = list(self.edge_list) if not self.edge_list: self.edge_list = [] + if not self.name: + self.name = type(self).__name__ self.di_graph = nx.to_networkx_graph(self.edge_list, create_using=nx.DiGraph) @@ -101,28 +100,17 @@ def __init__(self, *args, **kwargs): if node.label: label = node.label else: - label, last_letters = self._default_name(node, last_letters) - - label = node.label if node.label else self._default_name(node, last_letters) - attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", "fontsize": f"{node.fontsize}"} # add more here + label, last_letters = _default_label(node, last_letters) + attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", + "fontsize": f"{self.font_size}"} self.di_graph.nodes[node].update(attr) - def __len__(self): - return len(self.di_graph) - - def _default_name(self, node, last_letters): - node_type = str(type(node)).split('.')[-1][:-2] - last_letter = last_letters[node_type] - current_letter = auc[auc.index(last_letter) + 1] - last_letters[node_type] = current_letter - return node_type + ' ' + current_letter, last_letters - - @property def node_set(self): return set(self.di_graph.nodes) - def plot(self, dir_path, filename=None, use_positions=True, plot_title=False): + def plot(self, dir_path, filename=None, use_positions=True, plot_title=False, + bgcolour="lightgray", node_style="filled"): """Creates a pdf plot of the directed graph and displays it :param dir_path: The path to save the pdf and .gv files to @@ -131,19 +119,37 @@ def plot(self, dir_path, filename=None, use_positions=True, plot_title=False): :param plot_title: If a string is supplied, makes this the title of the plot. If True, uses the name attribute of the graph to title the plot. If False, no title is used. Default is False + :param bgcolour: String containing the background colour for the plot. + Default is "lightgray". See graphviz attributes for more information. + One alternative is "white" + :param node_style: String containing the node style for the plot. + Default is "filled". See graphviz attributes for more information. + One alternative is "solid" :return: """ + if use_positions: + for node in self.di_graph.nodes: + if not isinstance(node.position, Tuple): + raise TypeError("If use_positions is set to True, every node must have a " + "position, given as a Tuple of length 2") + attr = {"pos": f"{node.position[0]},{node.position[1]}!"} + self.di_graph.nodes[node].update(attr) dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() + dot_split = dot.split('\n') + dot_split.insert(1, f"graph [bgcolor={bgcolour}]") + dot_split.insert(1, f"node [style={node_style}]") + dot = "\n".join(dot_split) + print(dot) if plot_title: if plot_title is True: - title = self.name + plot_title = self.name elif not isinstance(plot_title, str): raise ValueError("Plot title must be a string, or True") - dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{title}\";" + "}" + dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{plot_title}\";" + "}" # print(dot) if not filename: filename = self.name - viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path) + viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') viz_graph.view() @property @@ -199,3 +205,28 @@ class CombinedArchitecture(Type): network_architecture: NetworkArchitecture = Property( doc="The architecture for how data is propagated through the network. Node A is connected " "to Node B if and only if A sends its data through B. ") + + +def _default_label(node, last_letters): # Moved as we don't use "self", so no need to be a class method + """Utility function to generate default labels for nodes, where none are given + Takes a node, and a dictionary with the letters last used for each class, + ie `last_letters['Node']` might return 'AA', meaning the last Node was labelled 'Node AA'""" + node_type = type(node).__name__ # Neater way than all that splitting and indexing + type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' + new_letters = _default_letters(type_letters) + last_letters[node_type] = new_letters + return node_type + ' ' + new_letters, last_letters + + +def _default_letters(type_letters): + last_letter = type_letters[-1] + if last_letter == 'Z': + # do something recursive + new_letters = 'A' # replace me + else: + # the easier case + current_letter = auc[auc.index(last_letter) + 1] + # Not quite done... + new_letters = 'A' # replace me + return new_letters # A string like 'A', or 'AAB' + diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index dd8e8ec36..efd520dc6 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -1,12 +1,14 @@ from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ - CombinedArchitecture, ProcessingNode, RepeaterNode + CombinedArchitecture, ProcessingNode, RepeaterNode, SensorNode +from ...sensor.base import PlatformMountable + import pytest def test_architecture(): - a, b, c, d, e = RepeaterNode("a"), RepeaterNode("b"), RepeaterNode("c"), RepeaterNode("d"), \ - RepeaterNode("e") + a, b, c, d, e = RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode(), \ + RepeaterNode() edge_list_unconnected = [(a, b), (c, d)] with pytest.raises(ValueError): @@ -22,7 +24,16 @@ def test_architecture(): assert a_test_hier.is_hierarchical assert not a_test_loop.is_hierarchical - a_test_hier.plot(dir_path='U:\\My Documents\\temp') + with pytest.raises(TypeError): + a_test_hier.plot(dir_path='U:\\My Documents\\temp', plot_title=True, use_positions=True) + + a_pos, b_pos, c_pos = RepeaterNode(label="Alpha", position=(1, 2)), \ + SensorNode(sensor=PlatformMountable(), position=(1, 1)), \ + RepeaterNode(position=(2, 1)) + edge_list_pos = [(a_pos, b_pos), (b_pos, c_pos), (c_pos, a_pos)] + pos_test = NetworkArchitecture(edge_list_pos) + pos_test.plot(dir_path='C:\\Users\\orosoman\\Desktop\\arch_plots', plot_title=True, + use_positions=True) def test_information_architecture(): From cebf0dddb610c2c20e8762970a3a605d83e56302 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 31 Jan 2023 17:39:16 +0000 Subject: [PATCH 010/170] Improvement to plot function --- stonesoup/types/architecture.py | 38 +++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index f86cad483..623d59d3c 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -47,7 +47,7 @@ def __init__(self, *args, **kwargs): if not self.colour: self.colour = '#006400' if not self.shape: - self.shape = 'triangle' + self.shape = 'hexagon' class RepeaterNode(Node): @@ -65,7 +65,7 @@ class Architecture(Type): edge_list: Collection = Property( default=None, doc="A Collection of edges between nodes. For A to be connected to B we would have (A, B)" - "be a member of this list. Default is None") + "be a member of this list. Default is an empty list") name: str = Property( default=None, doc="A name for the architecture, to be used to name files and/or title plots. Default is " @@ -78,6 +78,9 @@ class Architecture(Type): font_size: int = Property( default=8, doc='Font size for node labels') + node_dim: tuple = Property( + default=(0.5, 0.5), + doc='Height and width of nodes for graph icons, default is (1, 1)') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -95,21 +98,23 @@ def __init__(self, *args, **kwargs): "if you wish to override this requirement") # Set attributes such as label, colour, shape, etc for each node - last_letters = {'SensorNode': 'A', 'ProcessingNode': 'A', 'RepeaterNode': 'A'} + last_letters = {'SensorNode': '', 'ProcessingNode': '', 'RepeaterNode': ''} for node in self.di_graph.nodes: if node.label: label = node.label else: label, last_letters = _default_label(node, last_letters) + node.label = label attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", - "fontsize": f"{self.font_size}"} + "fontsize": f"{self.font_size}", "width": f"{self.node_dim[0]}", "height": f"{self.node_dim[1]}", + "fixedsize": True} self.di_graph.nodes[node].update(attr) @property def node_set(self): return set(self.di_graph.nodes) - def plot(self, dir_path, filename=None, use_positions=True, plot_title=False, + def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, bgcolour="lightgray", node_style="filled"): """Creates a pdf plot of the directed graph and displays it @@ -219,14 +224,19 @@ def _default_label(node, last_letters): # Moved as we don't use "self", so no n def _default_letters(type_letters): - last_letter = type_letters[-1] - if last_letter == 'Z': - # do something recursive - new_letters = 'A' # replace me - else: - # the easier case - current_letter = auc[auc.index(last_letter) + 1] - # Not quite done... - new_letters = 'A' # replace me + if type_letters == '': + return'A' + count = 0 + letters_list = [*type_letters] + # Move through string from right to left and shift any Z's up to A's + while letters_list[-1 - count] == 'Z': + letters_list[-1 - count] = 'A' + count += 1 + if count == len(letters_list): + return 'A' * (count + 1) + # Shift current letter up by one + current_letter = letters_list[-1-count] + letters_list[-1-count] = auc[auc.index(current_letter)+1] + new_letters = ''.join(letters_list) return new_letters # A string like 'A', or 'AAB' From d567c40b1383858ef699f72b1169c906e5066e09 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 2 Feb 2023 09:14:35 +0000 Subject: [PATCH 011/170] Add SensorSuite function --- stonesoup/types/architecture.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 623d59d3c..e29bc4384 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -114,6 +114,15 @@ def __init__(self, *args, **kwargs): def node_set(self): return set(self.di_graph.nodes) + @property + def sensor_suite(self): + sensor_list = [] + attr_set = set() # Attributes for sensor management, I believe. To worry about later - OR + for node in self.node_set: + if isinstance(node, SensorNode): + sensor_list.append(node.sensor) + return SensorSuite(sensor_list, attr_set) + def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, bgcolour="lightgray", node_style="filled"): """Creates a pdf plot of the directed graph and displays it From c84aa39c001f6312f3622396cc30e1159316b198 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Mon, 6 Feb 2023 18:57:10 +0000 Subject: [PATCH 012/170] Beginning of data measurement and propagation for architectures. --- stonesoup/types/architecture.py | 154 +++++++++++++++++++++++++++----- 1 file changed, 130 insertions(+), 24 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index e29bc4384..a9a1a00cc 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -1,11 +1,17 @@ +from abc import abstractmethod from ..base import Property from .base import Type from ..sensor.sensor import Sensor +from ..types.groundtruth import GroundTruthState +from ..types.detection import TrueDetection, Clutter, Detection +from ..types.hypothesis import Hypothesis -from typing import List, Collection, Tuple +from typing import List, Collection, Tuple, Set, Union, Dict +import numpy as np import networkx as nx import graphviz from string import ascii_uppercase as auc +from datetime import datetime class Node(Type): @@ -22,11 +28,25 @@ class Node(Type): shape: str = Property( default=None, doc='Shape used to display nodes') + data_held: Dict[datetime: Union[Detection, Hypothesis]] = Property( + default=None, + doc='Data or information held by this node') + + def __init__(self): + if not self.data_held: + self.data_held = [] + + def update(self, time, data): + if not isinstance(data, Detection) and not isinstance(data, Hypothesis): + raise TypeError("Data must be a Detection or Hypothesis") + if not isinstance(time, datetime): + raise TypeError("Time must be a datetime object") + if data not in self.data_held: + self.data_held[time] = data class SensorNode(Node): - """A node corresponding to a Sensor. Fresh data is created here, - and possibly processed as well""" + """A node corresponding to a Sensor. Fresh data is created here""" sensor: Sensor = Property(doc="Sensor corresponding to this node") def __init__(self, *args, **kwargs): @@ -35,8 +55,6 @@ def __init__(self, *args, **kwargs): self.colour = '#1f77b4' if not self.shape: self.shape = 'square' - # We'd only need to set font_size to something here - # if we wanted a different default for each class class ProcessingNode(Node): @@ -50,6 +68,16 @@ def __init__(self, *args, **kwargs): self.shape = 'hexagon' +class SensorProcessingNode(SensorNode, ProcessingNode): + """A node that is both a sensor and also processes data""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = 'something' # attr dict in Architecture.__init__ also needs updating + if not self.shape: + self.shape = 'something' + + class RepeaterNode(Node): """A node which simply passes data along to others, without manipulating the data itself. """ # Latency property could go here @@ -66,6 +94,10 @@ class Architecture(Type): default=None, doc="A Collection of edges between nodes. For A to be connected to B we would have (A, B)" "be a member of this list. Default is an empty list") + current_time: datetime = Property( + default=None, + doc="The time which the instance is at for the purpose of simulation. " + "This is increased by the propagate method, and defaults to the current system time") name: str = Property( default=None, doc="A name for the architecture, to be used to name files and/or title plots. Default is " @@ -90,6 +122,8 @@ def __init__(self, *args, **kwargs): self.edge_list = [] if not self.name: self.name = type(self).__name__ + if not self.current_time: + self.current_time = datetime.now() self.di_graph = nx.to_networkx_graph(self.edge_list, create_using=nx.DiGraph) @@ -106,22 +140,45 @@ def __init__(self, *args, **kwargs): label, last_letters = _default_label(node, last_letters) node.label = label attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", - "fontsize": f"{self.font_size}", "width": f"{self.node_dim[0]}", "height": f"{self.node_dim[1]}", - "fixedsize": True} + "fontsize": f"{self.font_size}", "width": f"{self.node_dim[0]}", + "height": f"{self.node_dim[1]}", "fixedsize": True} self.di_graph.nodes[node].update(attr) + def descendants(self, node: Node): + """Returns a set of all nodes to which the input node has a direct edge to""" + if node not in self.all_nodes: + raise ValueError("Node not in this architecture") + descendants = set() + for other in self.all_nodes: + if (node, other) in self.edge_list: + descendants.add(other) + return descendants + + def ancestors(self, node: Node): + """Returns a set of all nodes to which the input node has a direct edge from""" + if node not in self.all_nodes: + raise ValueError("Node not in this architecture") + ancestors = set() + for other in self.all_nodes: + if (other, node) in self.edge_list: + ancestors.add(other) + return ancestors + + @abstractmethod + def propagate(self, time_increment: float): + raise NotImplementedError + @property - def node_set(self): + def all_nodes(self): return set(self.di_graph.nodes) @property - def sensor_suite(self): - sensor_list = [] - attr_set = set() # Attributes for sensor management, I believe. To worry about later - OR - for node in self.node_set: + def sensor_nodes(self): + sensors = set() + for node in self.all_nodes: if isinstance(node, SensorNode): - sensor_list.append(node.sensor) - return SensorSuite(sensor_list, attr_set) + sensors.add(node.sensor) + return sensors def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, bgcolour="lightgray", node_style="filled"): @@ -170,7 +227,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, def density(self): """Returns the density of the graph, ie. the proportion of possible edges between nodes that exist in the graph""" - num_nodes = len(self.node_set) + num_nodes = len(self.all_nodes) num_edges = len(self.edge_list) architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) return architecture_density @@ -202,14 +259,55 @@ class InformationArchitecture(Architecture): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - for node in self.node_set: + for node in self.all_nodes: if isinstance(node, RepeaterNode): raise TypeError("Information architecture should not contain any repeater nodes") + def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.ndarray] = True, + **kwargs) -> Dict[SensorNode: Union[TrueDetection, Clutter]]: + """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ + all_detections = dict() + + for sensor_node in self.sensor_nodes: + + all_detections[sensor_node] = sensor_node.sensor.measure(ground_truths, noise, + **kwargs) + + attributes_dict = {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) + for attribute_name in self.attributes_inform} + + for detection in all_detections[sensor_node]: + detection.metadata.update(attributes_dict) + + for data in all_detections[sensor_node]: + # The sensor acquires its own data instantly + sensor_node.update(self.current_time, data) + + return all_detections + + def propagate(self, time_increment: float, failed_edges: Collection = None): + """Performs a single step of the propagation of the measurements through the network""" + self.current_time += datetime.timedelta(seconds=time_increment) + for node in self.all_nodes: + for descendant in self.descendants(node): + if (node, descendant) in failed_edges: + # The network architecture / some outside factor prevents information from node + # being transferred to other + continue + for data in node.data_held: + descendant.update(self.current_time, data) + class NetworkArchitecture(Architecture): """The architecture for how data is propagated through the network. Node A is connected " "to Node B if and only if A sends its data through B. """ + def propagate(self, time_increment: float): + # Still have to deal with latency/bandwidth + self.current_time += datetime.timedelta(seconds=time_increment) + for node in self.all_nodes: + for descendant in self.descendants(node): + for data in node.data_held: + descendant.update(self.current_time, data) class CombinedArchitecture(Type): @@ -220,21 +318,30 @@ class CombinedArchitecture(Type): doc="The architecture for how data is propagated through the network. Node A is connected " "to Node B if and only if A sends its data through B. ") + def propagate(self, time_increment: float): + # First we simulate the network + self.network_architecture.propagate(time_increment) + # Now we want to only pass information along in the information architecture if it + # Was in the information architecture by at least one path. + # Some magic here + failed_edges = [] # return this from n_arch.propagate? + self.information_architecture(time_increment, failed_edges) -def _default_label(node, last_letters): # Moved as we don't use "self", so no need to be a class method + +def _default_label(node, last_letters): """Utility function to generate default labels for nodes, where none are given Takes a node, and a dictionary with the letters last used for each class, ie `last_letters['Node']` might return 'AA', meaning the last Node was labelled 'Node AA'""" - node_type = type(node).__name__ # Neater way than all that splitting and indexing + node_type = type(node).__name__ type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' new_letters = _default_letters(type_letters) last_letters[node_type] = new_letters return node_type + ' ' + new_letters, last_letters -def _default_letters(type_letters): +def _default_letters(type_letters) -> str: if type_letters == '': - return'A' + return 'A' count = 0 letters_list = [*type_letters] # Move through string from right to left and shift any Z's up to A's @@ -244,8 +351,7 @@ def _default_letters(type_letters): if count == len(letters_list): return 'A' * (count + 1) # Shift current letter up by one - current_letter = letters_list[-1-count] - letters_list[-1-count] = auc[auc.index(current_letter)+1] + current_letter = letters_list[-1 - count] + letters_list[-1 - count] = auc[auc.index(current_letter) + 1] new_letters = ''.join(letters_list) - return new_letters # A string like 'A', or 'AAB' - + return new_letters From 50d47018971114f9fcd16eff72638d9cfcdf3292 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 7 Feb 2023 17:17:37 +0000 Subject: [PATCH 013/170] Fixes to code --- stonesoup/types/architecture.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index a9a1a00cc..f6947b23b 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -28,13 +28,14 @@ class Node(Type): shape: str = Property( default=None, doc='Shape used to display nodes') - data_held: Dict[datetime: Union[Detection, Hypothesis]] = Property( + data_held: Dict[datetime, Union[Detection, Hypothesis]] = Property( default=None, doc='Data or information held by this node') - def __init__(self): + def __init__(self,*args, **kwargs): + super().__init__(*args, **kwargs) if not self.data_held: - self.data_held = [] + self.data_held = dict() def update(self, time, data): if not isinstance(data, Detection) and not isinstance(data, Hypothesis): @@ -264,7 +265,7 @@ def __init__(self, *args, **kwargs): raise TypeError("Information architecture should not contain any repeater nodes") def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.ndarray] = True, - **kwargs) -> Dict[SensorNode: Union[TrueDetection, Clutter]]: + **kwargs) -> Dict[SensorNode, Union[TrueDetection, Clutter]]: """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ all_detections = dict() From 8f31038b86f89aadc4cfcc209b94fec87131a122 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Mon, 20 Feb 2023 01:47:17 +0000 Subject: [PATCH 014/170] Rough propogation for network architectures. --- stonesoup/tracker/simple.py | 2 +- stonesoup/types/architecture.py | 186 ++++++++++++++++++--- stonesoup/types/tests/test_architecture.py | 133 ++++++++++++++- 3 files changed, 297 insertions(+), 24 deletions(-) diff --git a/stonesoup/tracker/simple.py b/stonesoup/tracker/simple.py index 3371e1442..2854cb8d9 100644 --- a/stonesoup/tracker/simple.py +++ b/stonesoup/tracker/simple.py @@ -192,7 +192,7 @@ class MultiTargetTracker(Tracker): ---------- """ initiator: Initiator = Property(doc="Initiator used to initialise the track.") - deleter: Deleter = Property(doc="Initiator used to initialise the track.") + deleter: Deleter = Property(doc="Deleter used to delete tracks.") detector: DetectionReader = Property(doc="Detector used to generate detection objects.") data_associator: DataAssociator = Property( doc="Association algorithm to pair predictions to detections") diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index f6947b23b..4a6d4adbe 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -1,17 +1,25 @@ from abc import abstractmethod -from ..base import Property +from ..base import Property, Base from .base import Type from ..sensor.sensor import Sensor from ..types.groundtruth import GroundTruthState from ..types.detection import TrueDetection, Clutter, Detection from ..types.hypothesis import Hypothesis +from ..types.track import Track +from ..hypothesiser.base import Hypothesiser +from ..hypothesiser.probability import PDAHypothesiser +from ..predictor.base import Predictor +from ..updater.base import Updater +from ..dataassociator.base import DataAssociator +from ..initiator.base import Initiator +from ..deleter.base import Deleter from typing import List, Collection, Tuple, Set, Union, Dict import numpy as np import networkx as nx import graphviz from string import ascii_uppercase as auc -from datetime import datetime +from datetime import datetime, timedelta class Node(Type): @@ -28,22 +36,27 @@ class Node(Type): shape: str = Property( default=None, doc='Shape used to display nodes') - data_held: Dict[datetime, Union[Detection, Hypothesis]] = Property( + data_held: Dict[datetime, Set[Detection]] = Property( default=None, - doc='Data or information held by this node') + doc='Raw sensor data (Detection objects) held by this node') + hypotheses_held: Dict[Track, Dict[datetime, Set[Hypothesis]]] = Property( + default=None, + doc='Processed information (Hypothesis objects) held by this node') - def __init__(self,*args, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.data_held: self.data_held = dict() def update(self, time, data): - if not isinstance(data, Detection) and not isinstance(data, Hypothesis): - raise TypeError("Data must be a Detection or Hypothesis") if not isinstance(time, datetime): raise TypeError("Time must be a datetime object") - if data not in self.data_held: - self.data_held[time] = data + if not isinstance(data, Detection): + raise TypeError("Data provided without Track must be a Detection") + if time in self.data_held: + self.data_held[time].add(data) + else: + self.data_held[time] = {data} class SensorNode(Node): @@ -60,7 +73,19 @@ def __init__(self, *args, **kwargs): class ProcessingNode(Node): """A node that does not measure new data, but does process data it receives""" - # Latency property could go here + predictor: Predictor = Property( + doc="The predictor used by this node. ") + updater: Updater = Property( + doc="The updater used by this node. ") + hypothesiser: Hypothesiser = Property( + doc="The hypothesiser used by this node. ") + data_associator: DataAssociator = Property( + doc="The data associator used by this node. ") + initiator: Initiator = Property( + doc="The initiator used by this node") + deleter: Deleter = Property( + doc="The deleter used by this node") + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.colour: @@ -68,6 +93,76 @@ def __init__(self, *args, **kwargs): if not self.shape: self.shape = 'hexagon' + self.processed_data = dict() # Subset of data_held containing data that has been processed + self.unprocessed_data = dict() # Data that has not been processed yet + self.processed_hypotheses = dict() # Dict[track: Dict[datetime, Set[Hypothesis]]] + self.unprocessed_hypotheses = dict() # Hypotheses which have not yet been used + self.tracks = {} # Set of tracks this Node has recorded + + def process(self): + for time in self.unprocessed_data: + detections = self.unprocessed_data[time] + detect_hypotheses = self.data_associator.associate(self.tracks, detections, time) + + associated_detections = set() + for track in self.tracks: + detect_hypothesis = detect_hypotheses[track] + try: + track_hypotheses = self.unprocessed_hypotheses[track][time] + hypothesis = mean_combine({detect_hypothesis} + track_hypotheses, time) + except KeyError: # If there aren't any, use the one we have from our detections + hypothesis = detect_hypothesis + + if hypothesis.measurement: + post = self.updater.update(hypothesis) + track.append(post) + associated_detections.add(hypothesis.measurement) + else: # Keep prediction + track.append(hypothesis.prediction) + + # Create or delete tracks + self.tracks -= self.deleter.delete_tracks(self.tracks) + self.tracks |= self.initiator.initiate( + detections - associated_detections, time) + + # Send the unprocessed data that was just processed to processed_data + for time in self.unprocessed_data: + for data in self.unprocessed_data[time]: + if time in self.processed_data: + self.processed_data[time].add(data) + else: + self.processed_data[time] = {data} + # And same for hypotheses. We must ensure that we create sets before adding to them + for track in self.unprocessed_hypotheses: + for time in self.unprocessed_hypotheses[track]: + for data in self.unprocessed_hypotheses[track][time]: + if track in self.processed_hypotheses: + if time in self.processed_hypotheses[track]: + self.processed_hypotheses[track][time].add(data) + else: + self.hypotheses_held[track][time] = {data} + else: + self.hypotheses_held[track] = {time: data} + self.unprocessed_data = [] + self.unprocessed_hypotheses = [] + return + + def update(self, time, data, track=None): + if not track: + super().update(time, data) + else: + if not isinstance(time, datetime): + raise TypeError("Time must be a datetime object") + if not isinstance(data, Hypothesis): + raise TypeError("Data provided with Track must be a Hypothesis") + if track in self.hypotheses_held: + if time in self.hypotheses_held[track]: + self.hypotheses_held[track][time].add(data) + else: + self.hypotheses_held[track][time] = {data} + else: + self.hypotheses_held[track] = {time: {data}} + class SensorProcessingNode(SensorNode, ProcessingNode): """A node that is both a sensor and also processes data""" @@ -133,7 +228,8 @@ def __init__(self, *args, **kwargs): "if you wish to override this requirement") # Set attributes such as label, colour, shape, etc for each node - last_letters = {'SensorNode': '', 'ProcessingNode': '', 'RepeaterNode': ''} + last_letters = {'SensorNode': '', 'ProcessingNode': '', 'SensorProcessingNode': '', + 'RepeaterNode': ''} for node in self.di_graph.nodes: if node.label: label = node.label @@ -175,11 +271,19 @@ def all_nodes(self): @property def sensor_nodes(self): - sensors = set() + sensor_nodes = set() for node in self.all_nodes: if isinstance(node, SensorNode): - sensors.add(node.sensor) - return sensors + sensor_nodes.add(node) + return sensor_nodes + + @property + def processing_nodes(self): + processing = set() + for node in self.all_nodes: + if isinstance(node, ProcessingNode): + processing.add(node) + return processing def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, bgcolour="lightgray", node_style="filled"): @@ -274,11 +378,15 @@ def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.nd all_detections[sensor_node] = sensor_node.sensor.measure(ground_truths, noise, **kwargs) - attributes_dict = {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) - for attribute_name in self.attributes_inform} + # Borrowed below from SensorSuite. I don't think it's necessary, but might be something + # we need. If so, will need to define self.attributes_inform - for detection in all_detections[sensor_node]: - detection.metadata.update(attributes_dict) + # attributes_dict = \ + # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) + # for attribute_name in self.attributes_inform} + # + # for detection in all_detections[sensor_node]: + # detection.metadata.update(attributes_dict) for data in all_detections[sensor_node]: # The sensor acquires its own data instantly @@ -286,17 +394,26 @@ def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.nd return all_detections - def propagate(self, time_increment: float, failed_edges: Collection = None): + def propagate(self, time_increment: float, failed_edges: Collection = []): """Performs a single step of the propagation of the measurements through the network""" - self.current_time += datetime.timedelta(seconds=time_increment) + self.current_time += timedelta(seconds=time_increment) for node in self.all_nodes: for descendant in self.descendants(node): if (node, descendant) in failed_edges: # The network architecture / some outside factor prevents information from node # being transferred to other continue - for data in node.data_held: - descendant.update(self.current_time, data) + if node.data_held: + for time in node.data_held: + for data in node.data_held[time]: + descendant.update(time, data) + if node.hypotheses_held: + for track in node.hypotheses_held: + for time in node.hypotheses_held[track]: + for data in node.hypotheses_held[track][time]: + descendant.update(time, data, track) + for node in self.processing_nodes: + node.process() class NetworkArchitecture(Architecture): @@ -356,3 +473,28 @@ def _default_letters(type_letters) -> str: letters_list[-1 - count] = auc[auc.index(current_letter) + 1] new_letters = ''.join(letters_list) return new_letters + + +def mean_combine(objects: List, current_time: datetime = None): + """Combine a list of objects of the same type by averaging all numbers""" + this_type = type(objects[0]) + if any(type(obj) is not this_type for obj in objects): + raise TypeError("Objects must be of identical type") + + new_values = dict() + for name in type(objects[0]).properties: + value = getattr(objects[0], name) + if isinstance(value, (int, float, complex)) and not isinstance(value, bool): + # average em all + new_values[name] = np.mean([getattr(obj, name) for obj in objects]) + elif isinstance(value, datetime): + # Take the input time, as we likely want the current simulation time + new_values[name] = current_time + elif isinstance(value, Base): # if it's a Stone Soup object + # recurse + new_values[name] = mean_combine([getattr(obj, name) for obj in objects]) + else: + # just take 1st value + new_values[name] = getattr(objects[0], name) + + return this_type.__init__(**new_values) diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index efd520dc6..7f5791a23 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -1,11 +1,142 @@ from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ - CombinedArchitecture, ProcessingNode, RepeaterNode, SensorNode + CombinedArchitecture, ProcessingNode, RepeaterNode, SensorNode, SensorProcessingNode + from ...sensor.base import PlatformMountable +from stonesoup.models.measurement.categorical import MarkovianMeasurementModel +from stonesoup.models.transition.categorical import MarkovianTransitionModel +from stonesoup.types.groundtruth import CategoricalGroundTruthState +from stonesoup.types.state import CategoricalState +from stonesoup.types.groundtruth import GroundTruthPath +from stonesoup.sensor.categorical import HMMSensor +from stonesoup.predictor.categorical import HMMPredictor +from stonesoup.updater.categorical import HMMUpdater +from stonesoup.hypothesiser.categorical import HMMHypothesiser +from stonesoup.dataassociator.neighbour import GNNWith2DAssignment +from stonesoup.initiator.categorical import SimpleCategoricalMeasurementInitiator +from stonesoup.deleter.time import UpdateTimeStepsDeleter +from datetime import datetime, timedelta +import numpy as np +import matplotlib.pyplot as plt import pytest +def test_information_architecture_using_hmm(): + """Heavily inspired by the example: "Classifying Using HMM""" + + # Skip to line 88 for network architectures (rest is from hmm example) + + transition_matrix = np.array([[0.8, 0.2], # P(bike | bike), P(bike | car) + [0.4, 0.6]]) # P(car | bike), P(car | car) + category_transition = MarkovianTransitionModel(transition_matrix=transition_matrix) + + start = datetime.now() + + hidden_classes = ['bike', 'car'] + + # Generating ground truth + ground_truths = list() + for i in range(1, 4): # 4 targets + state_vector = np.zeros(2) # create a vector with 2 zeroes + state_vector[ + np.random.choice(2, 1, p=[1 / 2, 1 / 2])] = 1 # pick a random class out of the 2 + ground_truth_state = CategoricalGroundTruthState(state_vector, + timestamp=start, + categories=hidden_classes) + + ground_truth = GroundTruthPath([ground_truth_state], id=f"GT{i}") + + for _ in range(10): + new_vector = category_transition.function(ground_truth[-1], + noise=True, + time_interval=timedelta(seconds=1)) + new_state = CategoricalGroundTruthState( + new_vector, + timestamp=ground_truth[-1].timestamp + timedelta(seconds=1), + categories=hidden_classes + ) + + ground_truth.append(new_state) + ground_truths.append(ground_truth) + + E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) + [0.19, 0.3], # P(medium | bike), P(medium | car) + [0.01, 0.6]]) # P(large | bike), P(large | car) + model = MarkovianMeasurementModel(emission_matrix=E, + measurement_categories=['small', 'medium', 'large']) + + hmm_sensor = HMMSensor(measurement_model=model) + + transition_matrix = np.array([[0.81, 0.19], # P(bike | bike), P(bike | car) + [0.39, 0.61]]) # P(car | bike), P(car | car) + category_transition = MarkovianTransitionModel(transition_matrix=transition_matrix) + + predictor = HMMPredictor(category_transition) + + updater = HMMUpdater() + + hypothesiser = HMMHypothesiser(predictor=predictor, updater=updater) + + data_associator = GNNWith2DAssignment(hypothesiser) + + prior = CategoricalState([1 / 2, 1 / 2], categories=hidden_classes) + + initiator = SimpleCategoricalMeasurementInitiator(prior_state=prior, updater=updater) + + deleter = UpdateTimeStepsDeleter(2) + + # START HERE FOR THE GOOD STUFF + + hmm_sensor_node_A = SensorNode(sensor=hmm_sensor) + hmm_sensor_processing_node_B = SensorProcessingNode(sensor=hmm_sensor, predictor=predictor, + updater=updater, hypothesiser=hypothesiser, + data_associator=data_associator, + initiator=initiator, deleter=deleter) + network_architecture = InformationArchitecture( + edge_list=[(hmm_sensor_node_A, hmm_sensor_processing_node_B)], + current_time=start) + + for _ in range(10): + # Lots going on inside these two + # Ctrl + click to jump to source code for a class or function :) + + # Gets all SensorNodes (as SensorProcessingNodes inherit from SensorNodes, this is + # both the Nodes in this example) to measure + network_architecture.measure(ground_truths, noise=True) + # The data is propogated through the network, ie our SensorNode sends its measurements to + # the SensorProcessingNode. + network_architecture.propagate(time_increment=1) + + # OK, so this runs up to here, but something has gone wrong + tracks = hmm_sensor_processing_node_B.tracks + print(len(tracks)) + print(hmm_sensor_processing_node_B.data_held) + + # There is data, but no tracks... + + + def plot(path, style): + times = list() + probs = list() + for state in path: + times.append(state.timestamp) + probs.append(state.state_vector[0]) + plt.plot(times, probs, linestyle=style) + + # Node B is the 'parent' node, so we want its tracks. Also the only ProcessingNode + # in this example + + for truth in ground_truths: + plot(truth, '--') + for track in tracks: + plot(track, '-') + + plt.show; + + + + def test_architecture(): a, b, c, d, e = RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode(), \ RepeaterNode() From cb6ddf096e936b9a63e6ba20e8849f7b43a53f6f Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 21 Feb 2023 12:21:57 +0000 Subject: [PATCH 015/170] bug fixes/neatening for Node.update --- stonesoup/types/architecture.py | 70 +++++++++++----------- stonesoup/types/tests/test_architecture.py | 14 ++--- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 4a6d4adbe..9306eb267 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -48,15 +48,17 @@ def __init__(self, *args, **kwargs): if not self.data_held: self.data_held = dict() - def update(self, time, data): + def update(self, time, data, track=None): if not isinstance(time, datetime): raise TypeError("Time must be a datetime object") - if not isinstance(data, Detection): - raise TypeError("Data provided without Track must be a Detection") - if time in self.data_held: - self.data_held[time].add(data) + if not track: + if not isinstance(data, Detection): + raise TypeError("Data provided without Track must be a Detection") + _dict_set(self.data_held, data, time) else: - self.data_held[time] = {data} + if not isinstance(data, Hypothesis): + raise TypeError("Data provided with Track must be a Hypothesis") + _dict_set(self.hypotheses_held, data, track, time) class SensorNode(Node): @@ -95,7 +97,7 @@ def __init__(self, *args, **kwargs): self.processed_data = dict() # Subset of data_held containing data that has been processed self.unprocessed_data = dict() # Data that has not been processed yet - self.processed_hypotheses = dict() # Dict[track: Dict[datetime, Set[Hypothesis]]] + self.processed_hypotheses = dict() # Dict[track, Dict[datetime, Set[Hypothesis]]] self.unprocessed_hypotheses = dict() # Hypotheses which have not yet been used self.tracks = {} # Set of tracks this Node has recorded @@ -128,40 +130,23 @@ def process(self): # Send the unprocessed data that was just processed to processed_data for time in self.unprocessed_data: for data in self.unprocessed_data[time]: - if time in self.processed_data: - self.processed_data[time].add(data) - else: - self.processed_data[time] = {data} - # And same for hypotheses. We must ensure that we create sets before adding to them + _dict_set(self.processed_data, data, time) + # And same for hypotheses for track in self.unprocessed_hypotheses: for time in self.unprocessed_hypotheses[track]: for data in self.unprocessed_hypotheses[track][time]: - if track in self.processed_hypotheses: - if time in self.processed_hypotheses[track]: - self.processed_hypotheses[track][time].add(data) - else: - self.hypotheses_held[track][time] = {data} - else: - self.hypotheses_held[track] = {time: data} - self.unprocessed_data = [] - self.unprocessed_hypotheses = [] + _dict_set(self.processed_hypotheses, data, track, time) + + self.unprocessed_data = dict() + self.unprocessed_hypotheses = dict() return def update(self, time, data, track=None): + super().update(time, data, track) if not track: - super().update(time, data) + _dict_set(self.unprocessed_data, data, time) else: - if not isinstance(time, datetime): - raise TypeError("Time must be a datetime object") - if not isinstance(data, Hypothesis): - raise TypeError("Data provided with Track must be a Hypothesis") - if track in self.hypotheses_held: - if time in self.hypotheses_held[track]: - self.hypotheses_held[track][time].add(data) - else: - self.hypotheses_held[track][time] = {data} - else: - self.hypotheses_held[track] = {time: {data}} + _dict_set(self.unprocessed_hypotheses, data, track, time) class SensorProcessingNode(SensorNode, ProcessingNode): @@ -315,7 +300,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, dot_split.insert(1, f"graph [bgcolor={bgcolour}]") dot_split.insert(1, f"node [style={node_style}]") dot = "\n".join(dot_split) - print(dot) + #print(dot) if plot_title: if plot_title is True: plot_title = self.name @@ -498,3 +483,20 @@ def mean_combine(objects: List, current_time: datetime = None): new_values[name] = getattr(objects[0], name) return this_type.__init__(**new_values) + + +def _dict_set(my_dict, value, key1, key2=None): + """Utility function to add value to my_dict at the specified key(s)""" + if key2: + if key1 in my_dict: + if key2 in my_dict: + my_dict[key1][key2].add(value) + else: + my_dict[key1][key2] = {value} + else: + my_dict[key1] = {key2: value} + else: + if key1 in my_dict: + my_dict[key1].add(value) + else: + my_dict[key1] = {value} diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 7f5791a23..478a494a9 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -25,7 +25,7 @@ def test_information_architecture_using_hmm(): """Heavily inspired by the example: "Classifying Using HMM""" - # Skip to line 88 for network architectures (rest is from hmm example) + # Skip to line 89 for network architectures (rest is from hmm example) transition_matrix = np.array([[0.8, 0.2], # P(bike | bike), P(bike | car) [0.4, 0.6]]) # P(car | bike), P(car | car) @@ -93,7 +93,7 @@ def test_information_architecture_using_hmm(): updater=updater, hypothesiser=hypothesiser, data_associator=data_associator, initiator=initiator, deleter=deleter) - network_architecture = InformationArchitecture( + info_architecture = InformationArchitecture( edge_list=[(hmm_sensor_node_A, hmm_sensor_processing_node_B)], current_time=start) @@ -103,10 +103,10 @@ def test_information_architecture_using_hmm(): # Gets all SensorNodes (as SensorProcessingNodes inherit from SensorNodes, this is # both the Nodes in this example) to measure - network_architecture.measure(ground_truths, noise=True) - # The data is propogated through the network, ie our SensorNode sends its measurements to + info_architecture.measure(ground_truths, noise=True) + # The data is propagated through the network, ie our SensorNode sends its measurements to # the SensorProcessingNode. - network_architecture.propagate(time_increment=1) + info_architecture.propagate(time_increment=1) # OK, so this runs up to here, but something has gone wrong tracks = hmm_sensor_processing_node_B.tracks @@ -115,7 +115,6 @@ def test_information_architecture_using_hmm(): # There is data, but no tracks... - def plot(path, style): times = list() probs = list() @@ -132,8 +131,7 @@ def plot(path, style): for track in tracks: plot(track, '-') - plt.show; - + plt.show; # From 727a2c6b4a0476498050da3a31ff48a241124825 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Fri, 24 Feb 2023 01:57:56 +0000 Subject: [PATCH 016/170] making architectures gooder --- stonesoup/types/architecture.py | 153 ++++++++++++++++++++++++++------ 1 file changed, 125 insertions(+), 28 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 9306eb267..7685fad98 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -7,7 +7,6 @@ from ..types.hypothesis import Hypothesis from ..types.track import Track from ..hypothesiser.base import Hypothesiser -from ..hypothesiser.probability import PDAHypothesiser from ..predictor.base import Predictor from ..updater.base import Updater from ..dataassociator.base import DataAssociator @@ -54,11 +53,13 @@ def update(self, time, data, track=None): if not track: if not isinstance(data, Detection): raise TypeError("Data provided without Track must be a Detection") - _dict_set(self.data_held, data, time) + added, self.data_held = _dict_set(self.data_held, data, time) else: if not isinstance(data, Hypothesis): raise TypeError("Data provided with Track must be a Hypothesis") - _dict_set(self.hypotheses_held, data, track, time) + added, self.hypotheses_held = _dict_set(self.hypotheses_held, data, track, time) + + return added class SensorNode(Node): @@ -99,54 +100,86 @@ def __init__(self, *args, **kwargs): self.unprocessed_data = dict() # Data that has not been processed yet self.processed_hypotheses = dict() # Dict[track, Dict[datetime, Set[Hypothesis]]] self.unprocessed_hypotheses = dict() # Hypotheses which have not yet been used - self.tracks = {} # Set of tracks this Node has recorded + self.tracks = set() # Set of tracks this Node has recorded def process(self): - for time in self.unprocessed_data: - detections = self.unprocessed_data[time] - detect_hypotheses = self.data_associator.associate(self.tracks, detections, time) + unprocessed_times = {time for time in self.unprocessed_data} | \ + {time for track in self.unprocessed_hypotheses + for time in self.unprocessed_hypotheses[track]} + for time in unprocessed_times: + try: + detect_hypotheses = self.data_associator.associate(self.tracks, + self.unprocessed_data[time], + time) + except KeyError: + detect_hypotheses = False associated_detections = set() for track in self.tracks: - detect_hypothesis = detect_hypotheses[track] + detect_hypothesis = detect_hypotheses[track] if detect_hypotheses else False try: - track_hypotheses = self.unprocessed_hypotheses[track][time] - hypothesis = mean_combine({detect_hypothesis} + track_hypotheses, time) - except KeyError: # If there aren't any, use the one we have from our detections + # We deliberately re-use old hypotheses. If we just used the unprocessed ones + # then information would be lost + print(self.hypotheses_held[track][time]) + track_hypotheses = list(self.hypotheses_held[track][time]) + if detect_hypothesis: + hypothesis = mean_combine([detect_hypothesis] + track_hypotheses, time) + else: + hypothesis = mean_combine(track_hypotheses, time) + except TypeError: # If there aren't any, use the one we have from our detections hypothesis = detect_hypothesis + _, self.hypotheses_held = _dict_set(self.hypotheses_held, hypothesis, track, time) + _, self.processed_hypotheses = _dict_set(self.processed_hypotheses, + hypothesis, track, time) + if hypothesis.measurement: post = self.updater.update(hypothesis) - track.append(post) + _update_track(track, post, time) associated_detections.add(hypothesis.measurement) else: # Keep prediction - track.append(hypothesis.prediction) + _update_track(track, hypothesis.prediction, time) # Create or delete tracks - self.tracks -= self.deleter.delete_tracks(self.tracks) - self.tracks |= self.initiator.initiate( - detections - associated_detections, time) + self.tracks -= self.deleter.delete_tracks(self.tracks) + if self.unprocessed_data[time]: # If we had any detections + self.tracks |= self.initiator.initiate(self.unprocessed_data[time] - + associated_detections, time) # Send the unprocessed data that was just processed to processed_data for time in self.unprocessed_data: for data in self.unprocessed_data[time]: - _dict_set(self.processed_data, data, time) + _, self.processed_data = _dict_set(self.processed_data, data, time) # And same for hypotheses for track in self.unprocessed_hypotheses: for time in self.unprocessed_hypotheses[track]: for data in self.unprocessed_hypotheses[track][time]: - _dict_set(self.processed_hypotheses, data, track, time) + _, self.processed_hypotheses = _dict_set(self.processed_hypotheses, + data, track, time) self.unprocessed_data = dict() self.unprocessed_hypotheses = dict() return def update(self, time, data, track=None): - super().update(time, data, track) + print(track, type(data)) + if not super().update(time, data, track): + # Data was not new - do not add to unprocessed + return if not track: - _dict_set(self.unprocessed_data, data, time) + _, self.unprocessed_data = _dict_set(self.unprocessed_data, data, time) else: - _dict_set(self.unprocessed_hypotheses, data, track, time) + _, self.unprocessed_hypotheses = _dict_set(self.unprocessed_hypotheses, + data, track, time) + + +class NodeE(ProcessingNode): + def process(self): + super().process() + print(f"\n\n\n NEXT TIMESTEP \n\n\n") + print(f"{len(self.tracks)=}") + for track in self.tracks: + print(f"{len(track)=}") class SensorProcessingNode(SensorNode, ProcessingNode): @@ -341,6 +374,41 @@ def to_undirected(self): def __len__(self): return len(self.di_graph) + @property + def fully_propagated(self): + """Checks if all data and hypotheses that each node has have been transferred + to its descendants. + With zero latency, this should be the case after running self.propagate""" + for node in self.all_nodes: + for descendant in self.descendants(node): + try: + if node.data_held and node.hypotheses_held: + if not (all(node.data_held[time] <= descendant.data_held[time] + for time in node.data_held) and + all(node.hypotheses_held[track][time] <= + descendant.hypotheses_held[track][time] + for track in node.hypotheses_held + for time in node.hypotheses_held[track])): + return False + elif node.data_held: + if not (all(node.data_held[time] <= descendant.data_held[time] + for time in node.data_held)): + return False + elif node.hypotheses_held: + if not all(node.hypotheses_held[track][time] <= + descendant.hypotheses_held[track][time] + for track in node.hypotheses_held + for time in node.hypotheses_held[track]): + return False + + else: + # Node has no data, so has fully propagated + continue + except TypeError: + # descendant doesn't have all the keys node does + print("TypeError") + return False + return True class InformationArchitecture(Architecture): """The architecture for how information is shared through the network. Node A is " @@ -375,13 +443,13 @@ def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.nd for data in all_detections[sensor_node]: # The sensor acquires its own data instantly + print("data_type generated:\t", type(data)) sensor_node.update(self.current_time, data) return all_detections def propagate(self, time_increment: float, failed_edges: Collection = []): - """Performs a single step of the propagation of the measurements through the network""" - self.current_time += timedelta(seconds=time_increment) + """Performs the propagation of the measurements through the network""" for node in self.all_nodes: for descendant in self.descendants(node): if (node, descendant) in failed_edges: @@ -390,7 +458,10 @@ def propagate(self, time_increment: float, failed_edges: Collection = []): continue if node.data_held: for time in node.data_held: + print([time for time in node.data_held]) + print(f"{node.data_held[time]=}") for data in node.data_held[time]: + print("2nd type data", type(data)) descendant.update(time, data) if node.hypotheses_held: for track in node.hypotheses_held: @@ -399,6 +470,12 @@ def propagate(self, time_increment: float, failed_edges: Collection = []): descendant.update(time, data, track) for node in self.processing_nodes: node.process() + if not self.fully_propagated: + print(f"nope @ {self.current_time}") + self.propagate(time_increment, failed_edges) + return + print("increase time") + self.current_time += timedelta(seconds=time_increment) class NetworkArchitecture(Architecture): @@ -406,7 +483,7 @@ class NetworkArchitecture(Architecture): "to Node B if and only if A sends its data through B. """ def propagate(self, time_increment: float): # Still have to deal with latency/bandwidth - self.current_time += datetime.timedelta(seconds=time_increment) + self.current_time += timedelta(seconds=time_increment) for node in self.all_nodes: for descendant in self.descendants(node): for data in node.data_held: @@ -428,7 +505,7 @@ def propagate(self, time_increment: float): # Was in the information architecture by at least one path. # Some magic here failed_edges = [] # return this from n_arch.propagate? - self.information_architecture(time_increment, failed_edges) + self.information_architecture.propagate(time_increment, failed_edges) def _default_label(node, last_letters): @@ -470,7 +547,7 @@ def mean_combine(objects: List, current_time: datetime = None): for name in type(objects[0]).properties: value = getattr(objects[0], name) if isinstance(value, (int, float, complex)) and not isinstance(value, bool): - # average em all + # average them all new_values[name] = np.mean([getattr(obj, name) for obj in objects]) elif isinstance(value, datetime): # Take the input time, as we likely want the current simulation time @@ -486,17 +563,37 @@ def mean_combine(objects: List, current_time: datetime = None): def _dict_set(my_dict, value, key1, key2=None): - """Utility function to add value to my_dict at the specified key(s)""" - if key2: + """Utility function to add value to my_dict at the specified key(s) + Returns True iff the set increased in size, ie the value was new to its position""" + if not my_dict: + if key2: + my_dict = {key1: {key2: {value}}} + else: + my_dict = {key1: {value}} + elif key2: if key1 in my_dict: if key2 in my_dict: + old_len = len(my_dict) my_dict[key1][key2].add(value) + return len(my_dict) == old_len + 1, my_dict else: my_dict[key1][key2] = {value} else: my_dict[key1] = {key2: value} else: if key1 in my_dict: + old_len = len(my_dict) my_dict[key1].add(value) + return len(my_dict) == old_len + 1, my_dict else: my_dict[key1] = {value} + return True, my_dict + +def _update_track(track, state, time): + for state_num in range(len(track)): + if time == track[state_num].timestamp: + track[state_num] = state + return + track.append(state) + + From fb63b3b858420cb8d8e880042a5aa50389aa4857 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 7 Mar 2023 13:21:43 +0000 Subject: [PATCH 017/170] bug/fixes for Node.process(). Made SingleProbabilityHypothesis hashable --- stonesoup/types/architecture.py | 32 ++++++++------------------------ stonesoup/types/hypothesis.py | 4 ++++ 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 7685fad98..6def00d9a 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -116,17 +116,19 @@ def process(self): associated_detections = set() for track in self.tracks: + if not detect_hypotheses and track not in self.unprocessed_hypotheses: + # If this track has no new data + continue detect_hypothesis = detect_hypotheses[track] if detect_hypotheses else False try: # We deliberately re-use old hypotheses. If we just used the unprocessed ones # then information would be lost - print(self.hypotheses_held[track][time]) track_hypotheses = list(self.hypotheses_held[track][time]) if detect_hypothesis: hypothesis = mean_combine([detect_hypothesis] + track_hypotheses, time) else: hypothesis = mean_combine(track_hypotheses, time) - except TypeError: # If there aren't any, use the one we have from our detections + except (TypeError, KeyError): hypothesis = detect_hypothesis _, self.hypotheses_held = _dict_set(self.hypotheses_held, hypothesis, track, time) @@ -142,7 +144,7 @@ def process(self): # Create or delete tracks self.tracks -= self.deleter.delete_tracks(self.tracks) - if self.unprocessed_data[time]: # If we had any detections + if time in self.unprocessed_data: # If we had any detections self.tracks |= self.initiator.initiate(self.unprocessed_data[time] - associated_detections, time) @@ -162,7 +164,6 @@ def process(self): return def update(self, time, data, track=None): - print(track, type(data)) if not super().update(time, data, track): # Data was not new - do not add to unprocessed return @@ -173,15 +174,6 @@ def update(self, time, data, track=None): data, track, time) -class NodeE(ProcessingNode): - def process(self): - super().process() - print(f"\n\n\n NEXT TIMESTEP \n\n\n") - print(f"{len(self.tracks)=}") - for track in self.tracks: - print(f"{len(track)=}") - - class SensorProcessingNode(SensorNode, ProcessingNode): """A node that is both a sensor and also processes data""" def __init__(self, *args, **kwargs): @@ -333,14 +325,12 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, dot_split.insert(1, f"graph [bgcolor={bgcolour}]") dot_split.insert(1, f"node [style={node_style}]") dot = "\n".join(dot_split) - #print(dot) if plot_title: if plot_title is True: plot_title = self.name elif not isinstance(plot_title, str): raise ValueError("Plot title must be a string, or True") dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{plot_title}\";" + "}" - # print(dot) if not filename: filename = self.name viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') @@ -404,9 +394,8 @@ def fully_propagated(self): else: # Node has no data, so has fully propagated continue - except TypeError: + except TypeError: # Should this be KeyError? # descendant doesn't have all the keys node does - print("TypeError") return False return True @@ -443,7 +432,6 @@ def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.nd for data in all_detections[sensor_node]: # The sensor acquires its own data instantly - print("data_type generated:\t", type(data)) sensor_node.update(self.current_time, data) return all_detections @@ -458,10 +446,7 @@ def propagate(self, time_increment: float, failed_edges: Collection = []): continue if node.data_held: for time in node.data_held: - print([time for time in node.data_held]) - print(f"{node.data_held[time]=}") for data in node.data_held[time]: - print("2nd type data", type(data)) descendant.update(time, data) if node.hypotheses_held: for track in node.hypotheses_held: @@ -471,10 +456,8 @@ def propagate(self, time_increment: float, failed_edges: Collection = []): for node in self.processing_nodes: node.process() if not self.fully_propagated: - print(f"nope @ {self.current_time}") self.propagate(time_increment, failed_edges) return - print("increase time") self.current_time += timedelta(seconds=time_increment) @@ -579,7 +562,7 @@ def _dict_set(my_dict, value, key1, key2=None): else: my_dict[key1][key2] = {value} else: - my_dict[key1] = {key2: value} + my_dict[key1] = {key2: {value}} else: if key1 in my_dict: old_len = len(my_dict) @@ -589,6 +572,7 @@ def _dict_set(my_dict, value, key1, key2=None): my_dict[key1] = {value} return True, my_dict + def _update_track(track, state, time): for state_num in range(len(track)): if time == track[state_num].timestamp: diff --git a/stonesoup/types/hypothesis.py b/stonesoup/types/hypothesis.py index 12834bd14..35fc5cff8 100644 --- a/stonesoup/types/hypothesis.py +++ b/stonesoup/types/hypothesis.py @@ -101,6 +101,10 @@ def weight(self): class SingleProbabilityHypothesis(ProbabilityHypothesis, SingleHypothesis): """Single Measurement Probability scored hypothesis subclass.""" + def __hash__(self): + return hash((self.probability, self.prediction, self.measurement, + self.measurement_prediction)) + class JointHypothesis(Type, UserDict): """Joint Hypothesis base type From f5c7e4771326800392964afb1d857e3df068c11d Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Wed, 8 Mar 2023 13:33:10 +0000 Subject: [PATCH 018/170] Quick fix to architecture _set_dict() method --- stonesoup/types/architecture.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 6def00d9a..d2bb40b54 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -390,7 +390,6 @@ def fully_propagated(self): for track in node.hypotheses_held for time in node.hypotheses_held[track]): return False - else: # Node has no data, so has fully propagated continue @@ -399,6 +398,7 @@ def fully_propagated(self): return False return True + class InformationArchitecture(Architecture): """The architecture for how information is shared through the network. Node A is " "connected to Node B if and only if the information A creates by processing and/or " @@ -556,18 +556,18 @@ def _dict_set(my_dict, value, key1, key2=None): elif key2: if key1 in my_dict: if key2 in my_dict: - old_len = len(my_dict) + old_len = len(my_dict[key1][key2]) my_dict[key1][key2].add(value) - return len(my_dict) == old_len + 1, my_dict + return len(my_dict[key1][key2]) == old_len + 1, my_dict else: my_dict[key1][key2] = {value} else: my_dict[key1] = {key2: {value}} else: if key1 in my_dict: - old_len = len(my_dict) + old_len = len(my_dict[key1]) my_dict[key1].add(value) - return len(my_dict) == old_len + 1, my_dict + return len(my_dict[key1]) == old_len + 1, my_dict else: my_dict[key1] = {value} return True, my_dict From 9324470201267266d148ab77d6edfface11d41fd Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 8 Mar 2023 18:16:03 +0000 Subject: [PATCH 019/170] add message class --- stonesoup/types/architecture.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index d2bb40b54..6be325388 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -20,6 +20,23 @@ from string import ascii_uppercase as auc from datetime import datetime, timedelta +class Message(Type): + """A message, containing a piece of information, that gets propagated through the network. Messages are opened by + nodes that are a descendant of the node that sent the message""" + info: Union[Track, Hypothesis, Detection] = Property( + doc="Info that the sent message contains", + default=None) + generator_node: str = Property( + doc="id of the node that generated the message", + default=None) + sender_node: str = Property( + doc="id of last node that received the message", + default=None) + previous_nodes: list = Property( + doc="List of nodes that the message has been passed through", + default=None) + latency: float + class Node(Type): """Base node class""" @@ -173,6 +190,9 @@ def update(self, time, data, track=None): _, self.unprocessed_hypotheses = _dict_set(self.unprocessed_hypotheses, data, track, time) + def send_message(self, time, info): + # function to send message to descendant + class SensorProcessingNode(SensorNode, ProcessingNode): """A node that is both a sensor and also processes data""" @@ -448,11 +468,15 @@ def propagate(self, time_increment: float, failed_edges: Collection = []): for time in node.data_held: for data in node.data_held[time]: descendant.update(time, data) + # Send message to descendant here? + if node.hypotheses_held: for track in node.hypotheses_held: for time in node.hypotheses_held[track]: for data in node.hypotheses_held[track][time]: descendant.update(time, data, track) + # And send here as well? + # for node in self.processing_nodes: node.process() if not self.fully_propagated: From f1f9d31cff1bfad7531096369166b8f355b12b8e Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 14 Mar 2023 17:28:55 +0000 Subject: [PATCH 020/170] Add send_message and open_message functions --- stonesoup/types/architecture.py | 75 +++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 6be325388..020f4b756 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -26,16 +26,15 @@ class Message(Type): info: Union[Track, Hypothesis, Detection] = Property( doc="Info that the sent message contains", default=None) - generator_node: str = Property( - doc="id of the node that generated the message", + generator_node: Node = Property( + doc="Node that generated the message", default=None) - sender_node: str = Property( - doc="id of last node that received the message", + recipient_node: Node = Property( + doc="Node that receives the message", default=None) - previous_nodes: list = Property( - doc="List of nodes that the message has been passed through", + time_sent: datetime = Property( + doc="Time in datetime form at which the message was sent", default=None) - latency: float class Node(Type): @@ -52,17 +51,18 @@ class Node(Type): shape: str = Property( default=None, doc='Shape used to display nodes') - data_held: Dict[datetime, Set[Detection]] = Property( + data_held: Dict[str, Dict[datetime, Set[Union[Detection, Hypothesis, Track]]]] = Property( default=None, doc='Raw sensor data (Detection objects) held by this node') - hypotheses_held: Dict[Track, Dict[datetime, Set[Hypothesis]]] = Property( + messages_held: Dict[str, Dict[datetime, Set[Union[Detection, Hypothesis, Track]]]] = Property( default=None, - doc='Processed information (Hypothesis objects) held by this node') + doc='Dictionary containing two sub dictionaries, one of unopened messages and one of opened messages') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.data_held: self.data_held = dict() + self.received_messages = dict() def update(self, time, data, track=None): if not isinstance(time, datetime): @@ -78,6 +78,33 @@ def update(self, time, data, track=None): return added + def send_message(self, time_sent, data, recipient): + if not isinstance(data, (Detection, Hypothesis, Track)): + raise TypeError("Message info must be one of the following types: Detection, Hypothesis or Track") + # Add message to 'Unopened messages' dict of descendant node + message = Message(data, self, recipient) + _dict_set(recipient.received_messages, message, 'unopened', time_sent) # 2nd key will be time_sent+latency in future + + def open_messages(self, current_time): + # Check info type is what we expect + for time in self.received_messages['unopened']: + for message in self.received_messages['unopened'][time]: + if not isinstance(message.info, (Detection, Hypothesis, Track)): + raise TypeError("Message info must be one of the following types: Detection, Hypothesis or Track") + else: + if isinstance(data, Detection): + # Do something to put data in the right place + elif isinstance(data, Hypothesis): + # Do something to put data in the right place + elif isinstance(data, Track): + # Do something to put data in the right place + # Add message to opened messages + _dict_set(self.received_messages, message, 'opened', current_time) + # Remove message from unopened messages + self.received_messages['unopened'][time].remove(message) + + self.update(message.time_sent+message.latency, message.info) + class SensorNode(Node): """A node corresponding to a Sensor. Fresh data is created here""" @@ -91,7 +118,7 @@ def __init__(self, *args, **kwargs): self.shape = 'square' -class ProcessingNode(Node): +class FusionNode(Node): """A node that does not measure new data, but does process data it receives""" predictor: Predictor = Property( doc="The predictor used by this node. ") @@ -190,18 +217,16 @@ def update(self, time, data, track=None): _, self.unprocessed_hypotheses = _dict_set(self.unprocessed_hypotheses, data, track, time) - def send_message(self, time, info): - # function to send message to descendant -class SensorProcessingNode(SensorNode, ProcessingNode): +class SensorFusionNode(SensorNode, FusionNode): """A node that is both a sensor and also processes data""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.colour: - self.colour = 'something' # attr dict in Architecture.__init__ also needs updating + self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating if not self.shape: - self.shape = 'something' + self.shape = 'octagon' class RepeaterNode(Node): @@ -258,7 +283,7 @@ def __init__(self, *args, **kwargs): "if you wish to override this requirement") # Set attributes such as label, colour, shape, etc for each node - last_letters = {'SensorNode': '', 'ProcessingNode': '', 'SensorProcessingNode': '', + last_letters = {'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', 'RepeaterNode': ''} for node in self.di_graph.nodes: if node.label: @@ -311,7 +336,7 @@ def sensor_nodes(self): def processing_nodes(self): processing = set() for node in self.all_nodes: - if isinstance(node, ProcessingNode): + if isinstance(node, FusionNode): processing.add(node) return processing @@ -465,19 +490,13 @@ def propagate(self, time_increment: float, failed_edges: Collection = []): # being transferred to other continue if node.data_held: - for time in node.data_held: - for data in node.data_held[time]: - descendant.update(time, data) + for time in node.data_held['unsent']: + for data in node.data_held['unsent'][time]: # Send message to descendant here? + descendant.send_message(time, data) - if node.hypotheses_held: - for track in node.hypotheses_held: - for time in node.hypotheses_held[track]: - for data in node.hypotheses_held[track][time]: - descendant.update(time, data, track) - # And send here as well? - # for node in self.processing_nodes: + node.open_messages() node.process() if not self.fully_propagated: self.propagate(time_increment, failed_edges) From 176f91f4d59c1da1bb6d40786ea8332481152531 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 14 Mar 2023 18:09:24 +0000 Subject: [PATCH 021/170] Add latency, Edge, Edges --- stonesoup/types/architecture.py | 127 ++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 40 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 020f4b756..d8eedd7b4 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -20,25 +20,12 @@ from string import ascii_uppercase as auc from datetime import datetime, timedelta -class Message(Type): - """A message, containing a piece of information, that gets propagated through the network. Messages are opened by - nodes that are a descendant of the node that sent the message""" - info: Union[Track, Hypothesis, Detection] = Property( - doc="Info that the sent message contains", - default=None) - generator_node: Node = Property( - doc="Node that generated the message", - default=None) - recipient_node: Node = Property( - doc="Node that receives the message", - default=None) - time_sent: datetime = Property( - doc="Time in datetime form at which the message was sent", - default=None) - class Node(Type): """Base node class""" + latency: float = Property( + doc="Contribution to edge latency stemming from this node", + default=0) label: str = Property( doc="Label to be displayed on graph", default=None) @@ -91,19 +78,12 @@ def open_messages(self, current_time): for message in self.received_messages['unopened'][time]: if not isinstance(message.info, (Detection, Hypothesis, Track)): raise TypeError("Message info must be one of the following types: Detection, Hypothesis or Track") - else: - if isinstance(data, Detection): - # Do something to put data in the right place - elif isinstance(data, Hypothesis): - # Do something to put data in the right place - elif isinstance(data, Track): - # Do something to put data in the right place - # Add message to opened messages - _dict_set(self.received_messages, message, 'opened', current_time) - # Remove message from unopened messages - self.received_messages['unopened'][time].remove(message) - - self.update(message.time_sent+message.latency, message.info) + # Add message to opened messages + _dict_set(self.received_messages, message, 'opened', current_time) + # Remove message from unopened messages + self.received_messages['unopened'][time].remove(message) + # Update + self.update(message.time_sent+message.latency, message.info) class SensorNode(Node): @@ -240,11 +220,82 @@ def __init__(self, *args, **kwargs): self.shape = 'circle' +class Message(Type): + """A message, containing a piece of information, that gets propagated through the network. Messages are opened by + nodes that are a descendant of the node that sent the message""" + info: Union[Track, Hypothesis, Detection] = Property( + doc="Info that the sent message contains", + default=None) + generator_node: Node = Property( + doc="Node that generated the message", + default=None) + recipient_node: Node = Property( + doc="Node that receives the message", + default=None) + time_sent: datetime = Property( + doc="Time at which the message was sent", + default=None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.status = "sending" + + +class Edge(Type): + """Comprised of two connected Nodes""" + nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (ancestor, descendant") + edge_latency: float = Property(doc="The latency stemming from the edge itself, " + "and not either of the nodes", + default=0) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.messages_held = {"pending": set(), "received": set()} # this is how other xxxx_held should be made too, inside init + + @property + def ancestor(self): + return self.nodes[0] + + @property + def descendant(self): + return self.nodes[1] + + @property + def ovr_latency(self): + """Overall latency including the two Nodes and the edge latency""" + return self.ancestor.latency + self.edge_latency + self.descendant.latency + + +class Edges(Type): + """Container class for Edge""" + edges: List[Edge] = Property(doc="List of Edge objects", default=None) + + def add(self, edge): + self.edges.append(edge) + + def get(self, node_pair): + if not isinstance(node_pair, Tuple) and all(isinstance(node, Node) for node in node_pair): + raise TypeError("Must supply a tuple of nodes") + if not len(node_pair) == 2: + raise ValueError("Incorrect tuple length. Must be of length 2") + for edge in self.edges: + if edge.nodes == node_pair: + # Assume this is the only match? + return edge + return None + + @property + def edge_list(self): + edge_list = [] + for edge in self.edges: + edge_list.append(edge.nodes) + return edge_list + + class Architecture(Type): - edge_list: Collection = Property( - default=None, - doc="A Collection of edges between nodes. For A to be connected to B we would have (A, B)" - "be a member of this list. Default is an empty list") + edges: Edges = Property( + doc="An Edges object containing all edges. For A to be connected to B we would have an " + "Edge with edge_pair=(A, B) in this object.") current_time: datetime = Property( default=None, doc="The time which the instance is at for the purpose of simulation. " @@ -267,16 +318,12 @@ class Architecture(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if isinstance(self.edge_list, Collection) and not isinstance(self.edge_list, List): - self.edge_list = list(self.edge_list) - if not self.edge_list: - self.edge_list = [] if not self.name: self.name = type(self).__name__ if not self.current_time: self.current_time = datetime.now() - self.di_graph = nx.to_networkx_graph(self.edge_list, create_using=nx.DiGraph) + self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) if self.force_connected and not self.is_connected and len(self) > 0: raise ValueError("The graph is not connected. Use force_connected=False, " @@ -302,7 +349,7 @@ def descendants(self, node: Node): raise ValueError("Node not in this architecture") descendants = set() for other in self.all_nodes: - if (node, other) in self.edge_list: + if (node, other) in self.edges.edge_list: descendants.add(other) return descendants @@ -312,7 +359,7 @@ def ancestors(self, node: Node): raise ValueError("Node not in this architecture") ancestors = set() for other in self.all_nodes: - if (other, node) in self.edge_list: + if (other, node) in self.edges.edge_list: ancestors.add(other) return ancestors From 05d20275f5665285038dcfb57908ed20c4e53a0c Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 15 Mar 2023 14:58:41 +0000 Subject: [PATCH 022/170] Adjustments to send_message and update_message functions to account for edge class --- stonesoup/types/architecture.py | 42 +++++++++++++++++---------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index d8eedd7b4..9c5506906 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -65,25 +65,7 @@ def update(self, time, data, track=None): return added - def send_message(self, time_sent, data, recipient): - if not isinstance(data, (Detection, Hypothesis, Track)): - raise TypeError("Message info must be one of the following types: Detection, Hypothesis or Track") - # Add message to 'Unopened messages' dict of descendant node - message = Message(data, self, recipient) - _dict_set(recipient.received_messages, message, 'unopened', time_sent) # 2nd key will be time_sent+latency in future - def open_messages(self, current_time): - # Check info type is what we expect - for time in self.received_messages['unopened']: - for message in self.received_messages['unopened'][time]: - if not isinstance(message.info, (Detection, Hypothesis, Track)): - raise TypeError("Message info must be one of the following types: Detection, Hypothesis or Track") - # Add message to opened messages - _dict_set(self.received_messages, message, 'opened', current_time) - # Remove message from unopened messages - self.received_messages['unopened'][time].remove(message) - # Update - self.update(message.time_sent+message.latency, message.info) class SensorNode(Node): @@ -198,7 +180,6 @@ def update(self, time, data, track=None): data, track, time) - class SensorFusionNode(SensorNode, FusionNode): """A node that is both a sensor and also processes data""" def __init__(self, *args, **kwargs): @@ -252,6 +233,27 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.messages_held = {"pending": set(), "received": set()} # this is how other xxxx_held should be made too, inside init + def send_message(self, time_sent, data): + if not isinstance(data, (Detection, Hypothesis, Track)): + raise TypeError("Message info must be one of the following types: Detection, Hypothesis or Track") + # Add message to 'pending' dict of edge + message = Message(data, self.descendant, self.ancestor) + _dict_set(self.messages_held, message, 'pending', time_sent) # 2nd key will be time_sent+latency in future + + def update_messages(self, current_time): + # Check info type is what we expect + for time in self.messages_held['pending']: + for message in self.messages_held['pending'][time]: + if not isinstance(message.info, (Detection, Hypothesis, Track)): + raise TypeError("Message info must be one of the following types: Detection, Hypothesis or Track") + # Add message to unopened messages of ancestor + _dict_set(self.ancestor.messages_held, message, 'unopened', current_time) + # Move message from pending to received messages in edge + self.messages_held['pending'][time].remove(message) + _dict_set(self.messages_held, message, 'received', current_time) + # Update + self.ancestor.update(message.time_sent+message.latency, message.info) + @property def ancestor(self): return self.nodes[0] @@ -543,7 +545,7 @@ def propagate(self, time_increment: float, failed_edges: Collection = []): descendant.send_message(time, data) for node in self.processing_nodes: - node.open_messages() + node.update_messages() node.process() if not self.fully_propagated: self.propagate(time_increment, failed_edges) From 4aa0e1b04e2d96330120b7346f2256a02d02e3e7 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Fri, 17 Mar 2023 15:21:28 +0000 Subject: [PATCH 023/170] Further changes to Message and Edge classes. --- stonesoup/types/architecture.py | 123 ++++++++++++++------------------ 1 file changed, 52 insertions(+), 71 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 9c5506906..7dea7b9cf 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -12,6 +12,7 @@ from ..dataassociator.base import DataAssociator from ..initiator.base import Initiator from ..deleter.base import Deleter +from ..tracker.base import Tracker from typing import List, Collection, Tuple, Set, Union, Dict import numpy as np @@ -38,18 +39,11 @@ class Node(Type): shape: str = Property( default=None, doc='Shape used to display nodes') - data_held: Dict[str, Dict[datetime, Set[Union[Detection, Hypothesis, Track]]]] = Property( - default=None, - doc='Raw sensor data (Detection objects) held by this node') - messages_held: Dict[str, Dict[datetime, Set[Union[Detection, Hypothesis, Track]]]] = Property( - default=None, - doc='Dictionary containing two sub dictionaries, one of unopened messages and one of opened messages') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not self.data_held: - self.data_held = dict() - self.received_messages = dict() + self.data_held = {"processed": set(), "unprocessed": set()} + # Node no longer handles messages. All done by Edge def update(self, time, data, track=None): if not isinstance(time, datetime): @@ -61,13 +55,11 @@ def update(self, time, data, track=None): else: if not isinstance(data, Hypothesis): raise TypeError("Data provided with Track must be a Hypothesis") - added, self.hypotheses_held = _dict_set(self.hypotheses_held, data, track, time) + added, self.data_held = _dict_set(self.data_held, (data, track), time) return added - - class SensorNode(Node): """A node corresponding to a Sensor. Fresh data is created here""" sensor: Sensor = Property(doc="Sensor corresponding to this node") @@ -94,6 +86,9 @@ class FusionNode(Node): doc="The initiator used by this node") deleter: Deleter = Property( doc="The deleter used by this node") + tracker: Tracker = Property( + doc="Tracker used by this node. Only trackers which can handle the types of data fused by " + "the node will work.") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -101,11 +96,6 @@ def __init__(self, *args, **kwargs): self.colour = '#006400' if not self.shape: self.shape = 'hexagon' - - self.processed_data = dict() # Subset of data_held containing data that has been processed - self.unprocessed_data = dict() # Data that has not been processed yet - self.processed_hypotheses = dict() # Dict[track, Dict[datetime, Set[Hypothesis]]] - self.unprocessed_hypotheses = dict() # Hypotheses which have not yet been used self.tracks = set() # Set of tracks this Node has recorded def process(self): @@ -155,29 +145,12 @@ def process(self): associated_detections, time) # Send the unprocessed data that was just processed to processed_data - for time in self.unprocessed_data: - for data in self.unprocessed_data[time]: - _, self.processed_data = _dict_set(self.processed_data, data, time) - # And same for hypotheses - for track in self.unprocessed_hypotheses: - for time in self.unprocessed_hypotheses[track]: - for data in self.unprocessed_hypotheses[track][time]: - _, self.processed_hypotheses = _dict_set(self.processed_hypotheses, - data, track, time) - - self.unprocessed_data = dict() - self.unprocessed_hypotheses = dict() - return + for time in self.data_held['unprocessed']: + for data in self.data_held['unprocessed'][time]: + _, self.data_held['processed'] = _dict_set(self.data_held['processed'], data, time) - def update(self, time, data, track=None): - if not super().update(time, data, track): - # Data was not new - do not add to unprocessed - return - if not track: - _, self.unprocessed_data = _dict_set(self.unprocessed_data, data, time) - else: - _, self.unprocessed_hypotheses = _dict_set(self.unprocessed_hypotheses, - data, track, time) + self.data_held['unprocessed'] = set() + return class SensorFusionNode(SensorNode, FusionNode): @@ -201,27 +174,6 @@ def __init__(self, *args, **kwargs): self.shape = 'circle' -class Message(Type): - """A message, containing a piece of information, that gets propagated through the network. Messages are opened by - nodes that are a descendant of the node that sent the message""" - info: Union[Track, Hypothesis, Detection] = Property( - doc="Info that the sent message contains", - default=None) - generator_node: Node = Property( - doc="Node that generated the message", - default=None) - recipient_node: Node = Property( - doc="Node that receives the message", - default=None) - time_sent: datetime = Property( - doc="Time at which the message was sent", - default=None) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.status = "sending" - - class Edge(Type): """Comprised of two connected Nodes""" nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (ancestor, descendant") @@ -231,28 +183,32 @@ class Edge(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.messages_held = {"pending": set(), "received": set()} # this is how other xxxx_held should be made too, inside init + self.messages_held = {"pending": set(), "received": set()} def send_message(self, time_sent, data): if not isinstance(data, (Detection, Hypothesis, Track)): - raise TypeError("Message info must be one of the following types: Detection, Hypothesis or Track") + raise TypeError("Message info must be one of the following types: " + "Detection, Hypothesis or Track") # Add message to 'pending' dict of edge message = Message(data, self.descendant, self.ancestor) - _dict_set(self.messages_held, message, 'pending', time_sent) # 2nd key will be time_sent+latency in future + _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) def update_messages(self, current_time): # Check info type is what we expect for time in self.messages_held['pending']: for message in self.messages_held['pending'][time]: if not isinstance(message.info, (Detection, Hypothesis, Track)): - raise TypeError("Message info must be one of the following types: Detection, Hypothesis or Track") - # Add message to unopened messages of ancestor - _dict_set(self.ancestor.messages_held, message, 'unopened', current_time) - # Move message from pending to received messages in edge - self.messages_held['pending'][time].remove(message) - _dict_set(self.messages_held, message, 'received', current_time) - # Update - self.ancestor.update(message.time_sent+message.latency, message.info) + raise TypeError("Message info must be one of the following types: " + "Detection, Hypothesis or Track") + if message.time_sent + self.ovr_latency <= current_time: + # Then the latency has passed and message has been received + # No need for a Node to hold messages + # Move message from pending to received messages in edge + self.messages_held['pending'][time].remove(message) + _, self.messages_held = _dict_set(self.messages_held, message, + 'received', current_time) + # Update + message.recipient_node.update(message.time_sent+message.latency, message.info) @property def ancestor(self): @@ -294,6 +250,31 @@ def edge_list(self): return edge_list +class Message(Type): + """A message, containing a piece of information, that gets propagated between two Nodes. + Messages are opened by nodes that are a descendant of the node that sent the message""" + data: Union[Track, Hypothesis, Detection] = Property( + doc="Info that the sent message contains", + default=None) + edge: Edge = Property( + doc="The directed edge containing the sender and receiver of the message") + time_sent: datetime = Property( + doc="Time at which the message was sent", + default=None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.status = "sending" + + @property + def generator_node(self): + return self.edge.ancestor + + @property + def recipient_node(self): + return self.edge.descendant + + class Architecture(Type): edges: Edges = Property( doc="An Edges object containing all edges. For A to be connected to B we would have an " From 4a318e1297e8fe797057db3673fe989b3f368468 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Mon, 20 Mar 2023 18:13:13 +0000 Subject: [PATCH 024/170] added fixture to test_architecture --- stonesoup/types/architecture.py | 4 +-- stonesoup/types/tests/test_architecture.py | 30 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 7dea7b9cf..86115837e 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -23,7 +23,7 @@ class Node(Type): - """Base node class""" + """Base node class. Should be abstract""" latency: float = Property( doc="Contribution to edge latency stemming from this node", default=0) @@ -297,7 +297,7 @@ class Architecture(Type): doc='Font size for node labels') node_dim: tuple = Property( default=(0.5, 0.5), - doc='Height and width of nodes for graph icons, default is (1, 1)') + doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 478a494a9..d8335daea 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -1,5 +1,5 @@ from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ - CombinedArchitecture, ProcessingNode, RepeaterNode, SensorNode, SensorProcessingNode + CombinedArchitecture, FusionNode, RepeaterNode, SensorNode, Node, SensorFusionNode, Edge, Edges from ...sensor.base import PlatformMountable from stonesoup.models.measurement.categorical import MarkovianMeasurementModel @@ -21,6 +21,34 @@ import pytest +@pytest.fixture +def params(): + e = np.array([[0.8, 0.1], # P(small | bike), P(small | car) + [0.19, 0.3], # P(medium | bike), P(medium | car) + [0.01, 0.6]]) # P(large | bike), P(large | car) + model = MarkovianMeasurementModel(emission_matrix=e, + measurement_categories=['small', 'medium', 'large']) + hmm_sensor = HMMSensor(measurement_model=model) + nodes = SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor), SensorNode(hmm_sensor), \ + SensorNode(hmm_sensor) + + return {"small_nodes": nodes} # big nodes cna be added + + +def test_info_architecure_propagation(params): + """Is information correctly propagated through the architecture?""" + nodes = params['small_nodes'] + # A "Y" shape, with data travelling "up" the Y + # First, with no latency + edges = Edges([Edge((nodes[0], nodes[1])), Edge((nodes[1], nodes[2])), + Edge((nodes[1], nodes[3]))]) + architecture = InformationArchitecture(edges=edges) + + +def test_info_architecture_fusion(params): + """Are Fusion/SensorFusionNodes correctly fusing information together. + (Currently they won't be)""" + def test_information_architecture_using_hmm(): """Heavily inspired by the example: "Classifying Using HMM""" From 3b745027294bd69949e99c5b55669a75c43d0671 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Mon, 27 Mar 2023 12:12:33 +0100 Subject: [PATCH 025/170] halfway to fixing architecture propagation --- stonesoup/types/architecture.py | 159 +++++++++++++-------- stonesoup/types/tests/test_architecture.py | 142 ++++++++++++++++-- 2 files changed, 230 insertions(+), 71 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 86115837e..b14db3f3e 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -15,6 +15,7 @@ from ..tracker.base import Tracker from typing import List, Collection, Tuple, Set, Union, Dict +from itertools import product import numpy as np import networkx as nx import graphviz @@ -42,20 +43,24 @@ class Node(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.data_held = {"processed": set(), "unprocessed": set()} + self.data_held = {"processed": {}, "unprocessed": {}} # Node no longer handles messages. All done by Edge - def update(self, time, data, track=None): - if not isinstance(time, datetime): - raise TypeError("Time must be a datetime object") + def update(self, time_pertaining, time_arrived, data, track=None): + if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): + raise TypeError("Times must be datetime objects") if not track: - if not isinstance(data, Detection): - raise TypeError("Data provided without Track must be a Detection") - added, self.data_held = _dict_set(self.data_held, data, time) + if not isinstance(data, Detection) and not isinstance(data, Track): + raise TypeError("Data provided without accompanying Track must be a Detection or " + "a Track") + added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], + (data, time_arrived), time_pertaining) else: if not isinstance(data, Hypothesis): raise TypeError("Data provided with Track must be a Hypothesis") - added, self.data_held = _dict_set(self.data_held, (data, track), time) + added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], + (data, time_arrived, track), + time_pertaining) return added @@ -99,6 +104,7 @@ def __init__(self, *args, **kwargs): self.tracks = set() # Set of tracks this Node has recorded def process(self): + # deleting this soon unprocessed_times = {time for time in self.unprocessed_data} | \ {time for track in self.unprocessed_hypotheses for time in self.unprocessed_hypotheses[track]} @@ -183,32 +189,35 @@ class Edge(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.messages_held = {"pending": set(), "received": set()} + self.messages_held = {"pending": {}, "received": {}} - def send_message(self, time_sent, data): + def send_message(self, data, time_pertaining, time_sent): if not isinstance(data, (Detection, Hypothesis, Track)): raise TypeError("Message info must be one of the following types: " "Detection, Hypothesis or Track") # Add message to 'pending' dict of edge - message = Message(data, self.descendant, self.ancestor) + message = Message(self, time_pertaining, time_sent, data) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) def update_messages(self, current_time): # Check info type is what we expect + to_remove = set() # Needed as we can't change size of a set during iteration for time in self.messages_held['pending']: for message in self.messages_held['pending'][time]: - if not isinstance(message.info, (Detection, Hypothesis, Track)): - raise TypeError("Message info must be one of the following types: " - "Detection, Hypothesis or Track") - if message.time_sent + self.ovr_latency <= current_time: + message.update(current_time) + if message.status == 'received': # Then the latency has passed and message has been received # No need for a Node to hold messages # Move message from pending to received messages in edge - self.messages_held['pending'][time].remove(message) + to_remove.add(message) _, self.messages_held = _dict_set(self.messages_held, message, 'received', current_time) # Update - message.recipient_node.update(message.time_sent+message.latency, message.info) + message.recipient_node.update(message.time_pertaining, message.arrival_time, + message.data) + + for message in to_remove: + self.messages_held['pending'][time].remove(message) @property def ancestor(self): @@ -220,9 +229,30 @@ def descendant(self): @property def ovr_latency(self): - """Overall latency including the two Nodes and the edge latency""" + """Overall latency including the two Nodes and the edge latency.""" return self.ancestor.latency + self.edge_latency + self.descendant.latency + @property + def unsent_data(self): + """Data held by the ancestor that has not been sent to the descendant. + Current implementation should work but gross""" + unsent = [] + fudge = [] # this is a horrific way of doing it. To fix later + for comb in product(["unprocessed", "processed"], repeat=2): + for time_pertaining in self.ancestor.data_held[comb[0]]: + for data, _ in self.ancestor.data_held[comb[0]][time_pertaining]: + if time_pertaining not in self.descendant.data_held[comb[1]]: + if (data, time_pertaining) in fudge: + unsent.append((data, time_pertaining)) + else: + fudge.append((data, time_pertaining)) + elif data not in self.descendant.data_held[comb[1]][time_pertaining]: + if (data, time_pertaining) in fudge: + unsent.append((data, time_pertaining)) + else: + fudge.append((data, time_pertaining)) + return unsent + class Edges(Type): """Container class for Edge""" @@ -244,23 +274,32 @@ def get(self, node_pair): @property def edge_list(self): + """Returns a list of tuples in the form (ancestor, descendant)""" edge_list = [] for edge in self.edges: edge_list.append(edge.nodes) return edge_list + def __len__(self): + return len(self.edges) + class Message(Type): """A message, containing a piece of information, that gets propagated between two Nodes. Messages are opened by nodes that are a descendant of the node that sent the message""" - data: Union[Track, Hypothesis, Detection] = Property( - doc="Info that the sent message contains", - default=None) edge: Edge = Property( doc="The directed edge containing the sender and receiver of the message") + time_pertaining: datetime = Property( + doc="The latest time for which the data pertains. For a Detection, this would be the time " + "of the Detection, or for a Track this is the time of the last State in the Track. " + "Different from time_sent when data is passed on that was not generated by this " + "Node's ancestor") time_sent: datetime = Property( doc="Time at which the message was sent", default=None) + data: Union[Track, Hypothesis, Detection] = Property( + doc="Info that the sent message contains", + default=None) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -274,6 +313,21 @@ def generator_node(self): def recipient_node(self): return self.edge.descendant + @property + def arrival_time(self): + return self.time_sent + timedelta(seconds=self.edge.ovr_latency) + + def update(self, current_time): + progress = (current_time - self.time_sent).total_seconds() + if progress < self.edge.ancestor.latency: + self.status = "sending" + elif progress < self.edge.ancestor.latency + self.edge.edge_latency: + self.status = "transferring" + elif progress < self.edge.ovr_latency: + self.status = "receiving" + else: + self.status = "received" + class Architecture(Type): edges: Edges = Property( @@ -416,7 +470,7 @@ def density(self): """Returns the density of the graph, ie. the proportion of possible edges between nodes that exist in the graph""" num_nodes = len(self.all_nodes) - num_edges = len(self.edge_list) + num_edges = len(self.edges) architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) return architecture_density @@ -441,34 +495,19 @@ def __len__(self): @property def fully_propagated(self): - """Checks if all data and hypotheses that each node has have been transferred + """Checks if all data for each node have been transferred to its descendants. With zero latency, this should be the case after running self.propagate""" for node in self.all_nodes: for descendant in self.descendants(node): try: - if node.data_held and node.hypotheses_held: - if not (all(node.data_held[time] <= descendant.data_held[time] - for time in node.data_held) and - all(node.hypotheses_held[track][time] <= - descendant.hypotheses_held[track][time] - for track in node.hypotheses_held - for time in node.hypotheses_held[track])): - return False - elif node.data_held: - if not (all(node.data_held[time] <= descendant.data_held[time] - for time in node.data_held)): - return False - elif node.hypotheses_held: - if not all(node.hypotheses_held[track][time] <= - descendant.hypotheses_held[track][time] - for track in node.hypotheses_held - for time in node.hypotheses_held[track]): - return False - else: - # Node has no data, so has fully propagated - continue - except TypeError: # Should this be KeyError? + # 0 is for the actual data, not including arrival_time, which may be different + if not (all(node.data_held[status][time][0] <= + descendant.data_held[status][time][0] + for time in node.data_held) + for status in ["processed", "unprocessed"]): + return False + except TypeError: # Should this be KeyError? # descendant doesn't have all the keys node does return False return True @@ -486,7 +525,7 @@ def __init__(self, *args, **kwargs): raise TypeError("Information architecture should not contain any repeater nodes") def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.ndarray] = True, - **kwargs) -> Dict[SensorNode, Union[TrueDetection, Clutter]]: + **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ all_detections = dict() @@ -507,27 +546,21 @@ def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.nd for data in all_detections[sensor_node]: # The sensor acquires its own data instantly - sensor_node.update(self.current_time, data) + sensor_node.update(self.current_time, self.current_time, data) return all_detections - def propagate(self, time_increment: float, failed_edges: Collection = []): + def propagate(self, time_increment: float, failed_edges: Collection = None): """Performs the propagation of the measurements through the network""" - for node in self.all_nodes: - for descendant in self.descendants(node): - if (node, descendant) in failed_edges: - # The network architecture / some outside factor prevents information from node - # being transferred to other - continue - if node.data_held: - for time in node.data_held['unsent']: - for data in node.data_held['unsent'][time]: - # Send message to descendant here? - descendant.send_message(time, data) - - for node in self.processing_nodes: - node.update_messages() - node.process() + for edge in self.edges.edges: + if failed_edges and edge in failed_edges: + continue # in future, make it possible to simulate temporarily failed edges? + edge.update_messages(self.current_time) + for data, time_pertaining in edge.unsent_data: + edge.send_message(data, time_pertaining, self.current_time) + + # for node in self.processing_nodes: + # node.process() # This should happen when a new message is received if not self.fully_propagated: self.propagate(time_increment, failed_edges) return diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index d8335daea..4ae273555 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -1,5 +1,6 @@ from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ - CombinedArchitecture, FusionNode, RepeaterNode, SensorNode, Node, SensorFusionNode, Edge, Edges + CombinedArchitecture, FusionNode, RepeaterNode, SensorNode, Node, SensorFusionNode, Edge, \ + Edges, Message, _dict_set from ...sensor.base import PlatformMountable from stonesoup.models.measurement.categorical import MarkovianMeasurementModel @@ -14,6 +15,8 @@ from stonesoup.dataassociator.neighbour import GNNWith2DAssignment from stonesoup.initiator.categorical import SimpleCategoricalMeasurementInitiator from stonesoup.deleter.time import UpdateTimeStepsDeleter +from stonesoup.tracker.simple import SingleTargetTracker +from stonesoup.tracker.tests.conftest import detector # a useful fixture from datetime import datetime, timedelta import numpy as np @@ -21,33 +24,156 @@ import pytest + @pytest.fixture def params(): + transition_matrix = np.array([[0.8, 0.2], # P(bike | bike), P(bike | car) + [0.4, 0.6]]) # P(car | bike), P(car | car) + category_transition = MarkovianTransitionModel(transition_matrix=transition_matrix) + start = datetime.now() + + hidden_classes = ['bike', 'car'] + ground_truths = list() + for i in range(1, 4): # 4 targets + state_vector = np.zeros(2) # create a vector with 2 zeroes + state_vector[ + np.random.choice(2, 1, p=[1 / 2, 1 / 2])] = 1 # pick a random class out of the 2 + ground_truth_state = CategoricalGroundTruthState(state_vector, + timestamp=start, + categories=hidden_classes) + + ground_truth = GroundTruthPath([ground_truth_state], id=f"GT{i}") + + for _ in range(10): + new_vector = category_transition.function(ground_truth[-1], + noise=True, + time_interval=timedelta(seconds=1)) + new_state = CategoricalGroundTruthState( + new_vector, + timestamp=ground_truth[-1].timestamp + timedelta(seconds=1), + categories=hidden_classes + ) + + ground_truth.append(new_state) + ground_truths.append(ground_truth) + e = np.array([[0.8, 0.1], # P(small | bike), P(small | car) [0.19, 0.3], # P(medium | bike), P(medium | car) [0.01, 0.6]]) # P(large | bike), P(large | car) model = MarkovianMeasurementModel(emission_matrix=e, measurement_categories=['small', 'medium', 'large']) hmm_sensor = HMMSensor(measurement_model=model) - nodes = SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor), SensorNode(hmm_sensor), \ - SensorNode(hmm_sensor) - - return {"small_nodes": nodes} # big nodes cna be added - - -def test_info_architecure_propagation(params): + predictor = HMMPredictor(category_transition) + updater = HMMUpdater() + hypothesiser = HMMHypothesiser(predictor=predictor, updater=updater) + data_associator = GNNWith2DAssignment(hypothesiser) + prior = CategoricalState([1 / 2, 1 / 2], categories=hidden_classes) + initiator = SimpleCategoricalMeasurementInitiator(prior_state=prior, updater=updater) + deleter = UpdateTimeStepsDeleter(2) + tracker = SingleTargetTracker(initiator, deleter, detector, data_associator, updater) # Needs to be filled in + + nodes = SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor), SensorNode(hmm_sensor),\ + SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor) + lat_nodes = SensorNode(sensor=hmm_sensor, latency=0.3), SensorNode(sensor=hmm_sensor, latency=0.8975), SensorNode(hmm_sensor, latency=0.356),\ + SensorNode(sensor=hmm_sensor, latency=0.7), SensorNode(sensor=hmm_sensor, latency=0.5) + big_nodes = SensorFusionNode(sensor=hmm_sensor, + predictor=predictor, + updater=updater, + hypothesiser=hypothesiser, + data_associator=data_associator, + initiator=initiator, + deleter=deleter, + tracker=tracker), \ + FusionNode(predictor=predictor, + updater=updater, + hypothesiser=hypothesiser, + data_associator=data_associator, + initiator=initiator, + deleter=deleter, + tracker=tracker) + return {"small_nodes":nodes, "big_nodes": big_nodes, "ground_truths": ground_truths, "lat_nodes": lat_nodes} + + +def test_info_architecture_propagation(params): """Is information correctly propagated through the architecture?""" nodes = params['small_nodes'] + ground_truths = params['ground_truths'] + lat_nodes = params['lat_nodes'] + # A "Y" shape, with data travelling "up" the Y # First, with no latency edges = Edges([Edge((nodes[0], nodes[1])), Edge((nodes[1], nodes[2])), Edge((nodes[1], nodes[3]))]) architecture = InformationArchitecture(edges=edges) + for _ in range(10): + architecture.measure(ground_truths, noise=True) + architecture.propagate(time_increment=1, failed_edges=[]) + # Check all nodes hold 10 times with data pertaining, all in "unprocessed" + assert len(nodes[0].data_held['unprocessed']) == 10 + assert len(nodes[0].data_held['processed']) == 0 + # Check each time has exactly 1 piece of data + #assert all\ + print(len(nodes[0].data_held['unprocessed'][time]) == 1 + for time in nodes[0].data_held['unprocessed']) + # Check node 1 holds 20 messages, and its descendants (2 and 3) hold 30 each + assert len(nodes[1].data_held['unprocessed']) == 10 + assert len(nodes[2].data_held['unprocessed']) == 10 + assert len(nodes[3].data_held['unprocessed']) == 10 + # Check that the data held by node 0 is a subset of that of node 1, and so on for node 1 with + # its own descendants. Note this will only work with zero latency. + assert nodes[0].data_held['unprocessed'] <= nodes[1].data_held['unprocessed'] + assert nodes[1].data_held['unprocessed'] <= nodes[2].data_held['unprocessed'] \ + and nodes[1].data_held['unprocessed'] <= nodes[3].data_held['unprocessed'] + + # Architecture with latency (Same network as before) + edges_w_latency = Edges([Edge((lat_nodes[0], lat_nodes[1]), edge_latency=0.2), Edge((lat_nodes[1], lat_nodes[2]), edge_latency=0.5), + Edge((lat_nodes[1], lat_nodes[3]), edge_latency=0.3465234565634)]) + lat_architecture = InformationArchitecture(edges=edges_w_latency) + for _ in range(10): + lat_architecture.measure(ground_truths, noise=True) + lat_architecture.propagate(time_increment=1, failed_edges=[]) + assert len(nodes[0].data_held) < len(nodes[1].data_held) + # Check node 1 holds fewer messages than both its ancestors + assert len(nodes[1].data_held) < (len(nodes[2].data_held) and len(nodes[3].data_held)) + + #Error Tests + #Test descendants() + with pytest.raises(ValueError): + architecture.descendants(nodes[4]) + + #Test send_message() + with pytest.raises(TypeError): + data = float(1.23456) + the_time_is = datetime.now() + Edge((nodes[0], nodes[4])).send_message(time_sent=the_time_is, data=data) + + #Test update_message() + with pytest.raises(TypeError): + edge1 = Edge((nodes[0], nodes[4])) + data = float(1.23456) + the_time_is = datetime.now() + message = Message(data, nodes[0], nodes[4]) + _, edge1.messages_held = _dict_set(edge1.messages_held, message, 'pending', the_time_is) + edge1.update_messages(the_time_is) def test_info_architecture_fusion(params): """Are Fusion/SensorFusionNodes correctly fusing information together. (Currently they won't be)""" + nodes = params['small_nodes'] + big_nodes = params['big_nodes'] + ground_truths = params['ground_truths'] + + # A "Y" shape, with data travelling "down" the Y from 2 sensor nodes into one fusion node, and onto a SensorFusion node + edges = Edges([Edge((nodes[0], big_nodes[1])), Edge((nodes[1], big_nodes[1])), Edge((big_nodes[1], big_nodes[0]))]) + architecture = InformationArchitecture(edges=edges) + + for _ in range(10): + architecture.measure(ground_truths, noise=True) + architecture.propagate(time_increment=1, failed_edges=[]) + + assert something #to check data has been fused correctly at big_node[1] and big_node[0] + def test_information_architecture_using_hmm(): From 60d85be53af6f93a88ec1c1f92b89c67bb2e9901 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 23 Mar 2023 11:16:28 +0000 Subject: [PATCH 026/170] minor test changes and dictionary key change --- stonesoup/types/architecture.py | 159 ++++++++------------- stonesoup/types/tests/test_architecture.py | 105 ++------------ 2 files changed, 78 insertions(+), 186 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index b14db3f3e..af9841647 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -15,7 +15,6 @@ from ..tracker.base import Tracker from typing import List, Collection, Tuple, Set, Union, Dict -from itertools import product import numpy as np import networkx as nx import graphviz @@ -43,24 +42,20 @@ class Node(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.data_held = {"processed": {}, "unprocessed": {}} + self.data_held = {"processed": set(), "unprocessed": set()} # Node no longer handles messages. All done by Edge - def update(self, time_pertaining, time_arrived, data, track=None): - if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): - raise TypeError("Times must be datetime objects") + def update(self, time, data, track=None): + if not isinstance(time, datetime): + raise TypeError("Time must be a datetime object") if not track: - if not isinstance(data, Detection) and not isinstance(data, Track): - raise TypeError("Data provided without accompanying Track must be a Detection or " - "a Track") - added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], - (data, time_arrived), time_pertaining) + if not isinstance(data, Detection): + raise TypeError("Data provided without Track must be a Detection") + added, self.data_held = _dict_set(self.data_held, data, time) else: if not isinstance(data, Hypothesis): raise TypeError("Data provided with Track must be a Hypothesis") - added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], - (data, time_arrived, track), - time_pertaining) + added, self.data_held = _dict_set(self.data_held, (data, track), time) return added @@ -104,7 +99,6 @@ def __init__(self, *args, **kwargs): self.tracks = set() # Set of tracks this Node has recorded def process(self): - # deleting this soon unprocessed_times = {time for time in self.unprocessed_data} | \ {time for track in self.unprocessed_hypotheses for time in self.unprocessed_hypotheses[track]} @@ -189,35 +183,32 @@ class Edge(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.messages_held = {"pending": {}, "received": {}} + self.messages_held = {"pending": set(), "received": set()} - def send_message(self, data, time_pertaining, time_sent): + def send_message(self, time_sent, data): if not isinstance(data, (Detection, Hypothesis, Track)): raise TypeError("Message info must be one of the following types: " "Detection, Hypothesis or Track") # Add message to 'pending' dict of edge - message = Message(self, time_pertaining, time_sent, data) + message = Message(data, self.descendant, self.ancestor) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) def update_messages(self, current_time): # Check info type is what we expect - to_remove = set() # Needed as we can't change size of a set during iteration for time in self.messages_held['pending']: for message in self.messages_held['pending'][time]: - message.update(current_time) - if message.status == 'received': + if not isinstance(message.info, (Detection, Hypothesis, Track)): + raise TypeError("Message info must be one of the following types: " + "Detection, Hypothesis or Track") + if message.time_sent + self.ovr_latency <= current_time: # Then the latency has passed and message has been received # No need for a Node to hold messages # Move message from pending to received messages in edge - to_remove.add(message) + self.messages_held['pending'][time].remove(message) _, self.messages_held = _dict_set(self.messages_held, message, 'received', current_time) # Update - message.recipient_node.update(message.time_pertaining, message.arrival_time, - message.data) - - for message in to_remove: - self.messages_held['pending'][time].remove(message) + message.recipient_node.update(message.time_sent+message.latency, message.info) @property def ancestor(self): @@ -229,30 +220,9 @@ def descendant(self): @property def ovr_latency(self): - """Overall latency including the two Nodes and the edge latency.""" + """Overall latency including the two Nodes and the edge latency""" return self.ancestor.latency + self.edge_latency + self.descendant.latency - @property - def unsent_data(self): - """Data held by the ancestor that has not been sent to the descendant. - Current implementation should work but gross""" - unsent = [] - fudge = [] # this is a horrific way of doing it. To fix later - for comb in product(["unprocessed", "processed"], repeat=2): - for time_pertaining in self.ancestor.data_held[comb[0]]: - for data, _ in self.ancestor.data_held[comb[0]][time_pertaining]: - if time_pertaining not in self.descendant.data_held[comb[1]]: - if (data, time_pertaining) in fudge: - unsent.append((data, time_pertaining)) - else: - fudge.append((data, time_pertaining)) - elif data not in self.descendant.data_held[comb[1]][time_pertaining]: - if (data, time_pertaining) in fudge: - unsent.append((data, time_pertaining)) - else: - fudge.append((data, time_pertaining)) - return unsent - class Edges(Type): """Container class for Edge""" @@ -274,32 +244,23 @@ def get(self, node_pair): @property def edge_list(self): - """Returns a list of tuples in the form (ancestor, descendant)""" edge_list = [] for edge in self.edges: edge_list.append(edge.nodes) return edge_list - def __len__(self): - return len(self.edges) - class Message(Type): """A message, containing a piece of information, that gets propagated between two Nodes. Messages are opened by nodes that are a descendant of the node that sent the message""" + data: Union[Track, Hypothesis, Detection] = Property( + doc="Info that the sent message contains", + default=None) edge: Edge = Property( doc="The directed edge containing the sender and receiver of the message") - time_pertaining: datetime = Property( - doc="The latest time for which the data pertains. For a Detection, this would be the time " - "of the Detection, or for a Track this is the time of the last State in the Track. " - "Different from time_sent when data is passed on that was not generated by this " - "Node's ancestor") time_sent: datetime = Property( doc="Time at which the message was sent", default=None) - data: Union[Track, Hypothesis, Detection] = Property( - doc="Info that the sent message contains", - default=None) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -313,21 +274,6 @@ def generator_node(self): def recipient_node(self): return self.edge.descendant - @property - def arrival_time(self): - return self.time_sent + timedelta(seconds=self.edge.ovr_latency) - - def update(self, current_time): - progress = (current_time - self.time_sent).total_seconds() - if progress < self.edge.ancestor.latency: - self.status = "sending" - elif progress < self.edge.ancestor.latency + self.edge.edge_latency: - self.status = "transferring" - elif progress < self.edge.ovr_latency: - self.status = "receiving" - else: - self.status = "received" - class Architecture(Type): edges: Edges = Property( @@ -470,7 +416,7 @@ def density(self): """Returns the density of the graph, ie. the proportion of possible edges between nodes that exist in the graph""" num_nodes = len(self.all_nodes) - num_edges = len(self.edges) + num_edges = len(self.edge_list) architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) return architecture_density @@ -495,19 +441,34 @@ def __len__(self): @property def fully_propagated(self): - """Checks if all data for each node have been transferred + """Checks if all data and hypotheses that each node has have been transferred to its descendants. With zero latency, this should be the case after running self.propagate""" for node in self.all_nodes: for descendant in self.descendants(node): try: - # 0 is for the actual data, not including arrival_time, which may be different - if not (all(node.data_held[status][time][0] <= - descendant.data_held[status][time][0] - for time in node.data_held) - for status in ["processed", "unprocessed"]): - return False - except TypeError: # Should this be KeyError? + if node.data_held and node.hypotheses_held: + if not (all(node.data_held[time] <= descendant.data_held[time] + for time in node.data_held) and + all(node.hypotheses_held[track][time] <= + descendant.hypotheses_held[track][time] + for track in node.hypotheses_held + for time in node.hypotheses_held[track])): + return False + elif node.data_held: + if not (all(node.data_held[time] <= descendant.data_held[time] + for time in node.data_held)): + return False + elif node.hypotheses_held: + if not all(node.hypotheses_held[track][time] <= + descendant.hypotheses_held[track][time] + for track in node.hypotheses_held + for time in node.hypotheses_held[track]): + return False + else: + # Node has no data, so has fully propagated + continue + except TypeError: # Should this be KeyError? # descendant doesn't have all the keys node does return False return True @@ -525,7 +486,7 @@ def __init__(self, *args, **kwargs): raise TypeError("Information architecture should not contain any repeater nodes") def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.ndarray] = True, - **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: + **kwargs) -> Dict[SensorNode, Union[TrueDetection, Clutter]]: """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ all_detections = dict() @@ -546,21 +507,27 @@ def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.nd for data in all_detections[sensor_node]: # The sensor acquires its own data instantly - sensor_node.update(self.current_time, self.current_time, data) + sensor_node.update(self.current_time, data) return all_detections - def propagate(self, time_increment: float, failed_edges: Collection = None): + def propagate(self, time_increment: float, failed_edges: Collection = []): """Performs the propagation of the measurements through the network""" - for edge in self.edges.edges: - if failed_edges and edge in failed_edges: - continue # in future, make it possible to simulate temporarily failed edges? - edge.update_messages(self.current_time) - for data, time_pertaining in edge.unsent_data: - edge.send_message(data, time_pertaining, self.current_time) - - # for node in self.processing_nodes: - # node.process() # This should happen when a new message is received + for node in self.all_nodes: + for descendant in self.descendants(node): + if (node, descendant) in failed_edges: + # The network architecture / some outside factor prevents information from node + # being transferred to other + continue + if node.data_held: + for time in node.data_held['unprocessed']: + for data in node.data_held['unprocessed'][time]: + # Send message to descendant here? + descendant.send_message(time, data) + + for node in self.processing_nodes: + node.update_messages() + node.process() if not self.fully_propagated: self.propagate(time_increment, failed_edges) return diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 4ae273555..9b4de242e 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -1,6 +1,5 @@ from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ - CombinedArchitecture, FusionNode, RepeaterNode, SensorNode, Node, SensorFusionNode, Edge, \ - Edges, Message, _dict_set + CombinedArchitecture, FusionNode, RepeaterNode, SensorNode, Node, SensorFusionNode, Edge, Edges from ...sensor.base import PlatformMountable from stonesoup.models.measurement.categorical import MarkovianMeasurementModel @@ -15,8 +14,6 @@ from stonesoup.dataassociator.neighbour import GNNWith2DAssignment from stonesoup.initiator.categorical import SimpleCategoricalMeasurementInitiator from stonesoup.deleter.time import UpdateTimeStepsDeleter -from stonesoup.tracker.simple import SingleTargetTracker -from stonesoup.tracker.tests.conftest import detector # a useful fixture from datetime import datetime, timedelta import numpy as np @@ -24,7 +21,6 @@ import pytest - @pytest.fixture def params(): transition_matrix = np.array([[0.8, 0.2], # P(bike | bike), P(bike | car) @@ -70,36 +66,21 @@ def params(): prior = CategoricalState([1 / 2, 1 / 2], categories=hidden_classes) initiator = SimpleCategoricalMeasurementInitiator(prior_state=prior, updater=updater) deleter = UpdateTimeStepsDeleter(2) - tracker = SingleTargetTracker(initiator, deleter, detector, data_associator, updater) # Needs to be filled in - - nodes = SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor), SensorNode(hmm_sensor),\ - SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor) - lat_nodes = SensorNode(sensor=hmm_sensor, latency=0.3), SensorNode(sensor=hmm_sensor, latency=0.8975), SensorNode(hmm_sensor, latency=0.356),\ - SensorNode(sensor=hmm_sensor, latency=0.7), SensorNode(sensor=hmm_sensor, latency=0.5) - big_nodes = SensorFusionNode(sensor=hmm_sensor, - predictor=predictor, - updater=updater, - hypothesiser=hypothesiser, - data_associator=data_associator, - initiator=initiator, - deleter=deleter, - tracker=tracker), \ - FusionNode(predictor=predictor, - updater=updater, - hypothesiser=hypothesiser, - data_associator=data_associator, - initiator=initiator, - deleter=deleter, - tracker=tracker) - return {"small_nodes":nodes, "big_nodes": big_nodes, "ground_truths": ground_truths, "lat_nodes": lat_nodes} - - -def test_info_architecture_propagation(params): + + nodes = SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor), SensorNode(hmm_sensor), \ + SensorNode(hmm_sensor), FusionNode(predictor=predictor, + updater=updater, hypothesiser=hypothesiser, + data_associator=data_associator, + initiator=initiator, deleter=deleter) + + return {"small_nodes":nodes, "ground_truths":ground_truths} # big nodes cna be added + + +def test_info_architecure_propagation(params): """Is information correctly propagated through the architecture?""" nodes = params['small_nodes'] ground_truths = params['ground_truths'] - lat_nodes = params['lat_nodes'] - + print(ground_truths) # A "Y" shape, with data travelling "up" the Y # First, with no latency edges = Edges([Edge((nodes[0], nodes[1])), Edge((nodes[1], nodes[2])), @@ -109,71 +90,15 @@ def test_info_architecture_propagation(params): for _ in range(10): architecture.measure(ground_truths, noise=True) architecture.propagate(time_increment=1, failed_edges=[]) - # Check all nodes hold 10 times with data pertaining, all in "unprocessed" - assert len(nodes[0].data_held['unprocessed']) == 10 - assert len(nodes[0].data_held['processed']) == 0 - # Check each time has exactly 1 piece of data - #assert all\ - print(len(nodes[0].data_held['unprocessed'][time]) == 1 - for time in nodes[0].data_held['unprocessed']) - # Check node 1 holds 20 messages, and its descendants (2 and 3) hold 30 each - assert len(nodes[1].data_held['unprocessed']) == 10 - assert len(nodes[2].data_held['unprocessed']) == 10 - assert len(nodes[3].data_held['unprocessed']) == 10 - # Check that the data held by node 0 is a subset of that of node 1, and so on for node 1 with - # its own descendants. Note this will only work with zero latency. - assert nodes[0].data_held['unprocessed'] <= nodes[1].data_held['unprocessed'] - assert nodes[1].data_held['unprocessed'] <= nodes[2].data_held['unprocessed'] \ - and nodes[1].data_held['unprocessed'] <= nodes[3].data_held['unprocessed'] - - # Architecture with latency (Same network as before) - edges_w_latency = Edges([Edge((lat_nodes[0], lat_nodes[1]), edge_latency=0.2), Edge((lat_nodes[1], lat_nodes[2]), edge_latency=0.5), - Edge((lat_nodes[1], lat_nodes[3]), edge_latency=0.3465234565634)]) - lat_architecture = InformationArchitecture(edges=edges_w_latency) - for _ in range(10): - lat_architecture.measure(ground_truths, noise=True) - lat_architecture.propagate(time_increment=1, failed_edges=[]) - assert len(nodes[0].data_held) < len(nodes[1].data_held) - # Check node 1 holds fewer messages than both its ancestors - assert len(nodes[1].data_held) < (len(nodes[2].data_held) and len(nodes[3].data_held)) - - #Error Tests - #Test descendants() - with pytest.raises(ValueError): - architecture.descendants(nodes[4]) - #Test send_message() - with pytest.raises(TypeError): - data = float(1.23456) - the_time_is = datetime.now() - Edge((nodes[0], nodes[4])).send_message(time_sent=the_time_is, data=data) + assert something # Need to sort out issue with hypotheses_held - #Test update_message() - with pytest.raises(TypeError): - edge1 = Edge((nodes[0], nodes[4])) - data = float(1.23456) - the_time_is = datetime.now() - message = Message(data, nodes[0], nodes[4]) - _, edge1.messages_held = _dict_set(edge1.messages_held, message, 'pending', the_time_is) - edge1.update_messages(the_time_is) def test_info_architecture_fusion(params): """Are Fusion/SensorFusionNodes correctly fusing information together. (Currently they won't be)""" nodes = params['small_nodes'] - big_nodes = params['big_nodes'] - ground_truths = params['ground_truths'] - - # A "Y" shape, with data travelling "down" the Y from 2 sensor nodes into one fusion node, and onto a SensorFusion node - edges = Edges([Edge((nodes[0], big_nodes[1])), Edge((nodes[1], big_nodes[1])), Edge((big_nodes[1], big_nodes[0]))]) - architecture = InformationArchitecture(edges=edges) - - for _ in range(10): - architecture.measure(ground_truths, noise=True) - architecture.propagate(time_increment=1, failed_edges=[]) - - assert something #to check data has been fused correctly at big_node[1] and big_node[0] - + edges = Edges([Edge((nodes[0], nodes[2])), Edge((nodes[1], nodes[2]))]) def test_information_architecture_using_hmm(): From 66e123142fa23e00b782f62be7418ae26903552e Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 28 Mar 2023 14:46:06 +0100 Subject: [PATCH 027/170] small changes --- stonesoup/types/architecture.py | 160 ++++++++++++--------- stonesoup/types/tests/test_architecture.py | 109 ++++++++++++-- 2 files changed, 190 insertions(+), 79 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index af9841647..79914d432 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -15,6 +15,7 @@ from ..tracker.base import Tracker from typing import List, Collection, Tuple, Set, Union, Dict +from itertools import product import numpy as np import networkx as nx import graphviz @@ -42,20 +43,24 @@ class Node(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.data_held = {"processed": set(), "unprocessed": set()} + self.data_held = {"processed": {}, "unprocessed": {}} # Node no longer handles messages. All done by Edge - def update(self, time, data, track=None): - if not isinstance(time, datetime): - raise TypeError("Time must be a datetime object") + def update(self, time_pertaining, time_arrived, data, track=None): + if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): + raise TypeError("Times must be datetime objects") if not track: - if not isinstance(data, Detection): - raise TypeError("Data provided without Track must be a Detection") - added, self.data_held = _dict_set(self.data_held, data, time) + if not isinstance(data, Detection) and not isinstance(data, Track): + raise TypeError("Data provided without accompanying Track must be a Detection or " + "a Track") + added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], + (data, time_arrived), time_pertaining) else: if not isinstance(data, Hypothesis): raise TypeError("Data provided with Track must be a Hypothesis") - added, self.data_held = _dict_set(self.data_held, (data, track), time) + added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], + (data, time_arrived, track), + time_pertaining) return added @@ -99,6 +104,7 @@ def __init__(self, *args, **kwargs): self.tracks = set() # Set of tracks this Node has recorded def process(self): + # deleting this soon unprocessed_times = {time for time in self.unprocessed_data} | \ {time for track in self.unprocessed_hypotheses for time in self.unprocessed_hypotheses[track]} @@ -183,32 +189,35 @@ class Edge(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.messages_held = {"pending": set(), "received": set()} + self.messages_held = {"pending": {}, "received": {}} - def send_message(self, time_sent, data): + def send_message(self, data, time_pertaining, time_sent): if not isinstance(data, (Detection, Hypothesis, Track)): raise TypeError("Message info must be one of the following types: " "Detection, Hypothesis or Track") # Add message to 'pending' dict of edge - message = Message(data, self.descendant, self.ancestor) + message = Message(self, time_pertaining, time_sent, data) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) def update_messages(self, current_time): # Check info type is what we expect + to_remove = set() # Needed as we can't change size of a set during iteration for time in self.messages_held['pending']: for message in self.messages_held['pending'][time]: - if not isinstance(message.info, (Detection, Hypothesis, Track)): - raise TypeError("Message info must be one of the following types: " - "Detection, Hypothesis or Track") - if message.time_sent + self.ovr_latency <= current_time: + message.update(current_time) + if message.status == 'received': # Then the latency has passed and message has been received # No need for a Node to hold messages # Move message from pending to received messages in edge - self.messages_held['pending'][time].remove(message) + to_remove.add((time, message)) _, self.messages_held = _dict_set(self.messages_held, message, 'received', current_time) # Update - message.recipient_node.update(message.time_sent+message.latency, message.info) + message.recipient_node.update(message.time_pertaining, message.arrival_time, + message.data) + + for time, message in to_remove: + self.messages_held['pending'][time].remove(message) @property def ancestor(self): @@ -220,9 +229,30 @@ def descendant(self): @property def ovr_latency(self): - """Overall latency including the two Nodes and the edge latency""" + """Overall latency including the two Nodes and the edge latency.""" return self.ancestor.latency + self.edge_latency + self.descendant.latency + @property + def unsent_data(self): + """Data held by the ancestor that has not been sent to the descendant. + Current implementation should work but gross""" + unsent = [] + fudge = [] # this is a horrific way of doing it. To fix later + for comb in product(["unprocessed", "processed"], repeat=2): + for time_pertaining in self.ancestor.data_held[comb[0]]: + for data, _ in self.ancestor.data_held[comb[0]][time_pertaining]: + if time_pertaining not in self.descendant.data_held[comb[1]]: + if (data, time_pertaining) in fudge: + unsent.append((data, time_pertaining)) + else: + fudge.append((data, time_pertaining)) + elif data not in self.descendant.data_held[comb[1]][time_pertaining]: + if (data, time_pertaining) in fudge: + unsent.append((data, time_pertaining)) + else: + fudge.append((data, time_pertaining)) + return unsent + class Edges(Type): """Container class for Edge""" @@ -244,23 +274,32 @@ def get(self, node_pair): @property def edge_list(self): + """Returns a list of tuples in the form (ancestor, descendant)""" edge_list = [] for edge in self.edges: edge_list.append(edge.nodes) return edge_list + def __len__(self): + return len(self.edges) + class Message(Type): """A message, containing a piece of information, that gets propagated between two Nodes. Messages are opened by nodes that are a descendant of the node that sent the message""" - data: Union[Track, Hypothesis, Detection] = Property( - doc="Info that the sent message contains", - default=None) edge: Edge = Property( doc="The directed edge containing the sender and receiver of the message") + time_pertaining: datetime = Property( + doc="The latest time for which the data pertains. For a Detection, this would be the time " + "of the Detection, or for a Track this is the time of the last State in the Track. " + "Different from time_sent when data is passed on that was not generated by this " + "Node's ancestor") time_sent: datetime = Property( doc="Time at which the message was sent", default=None) + data: Union[Track, Hypothesis, Detection] = Property( + doc="Info that the sent message contains", + default=None) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -274,6 +313,21 @@ def generator_node(self): def recipient_node(self): return self.edge.descendant + @property + def arrival_time(self): + return self.time_sent + timedelta(seconds=self.edge.ovr_latency) + + def update(self, current_time): + progress = (current_time - self.time_sent).total_seconds() + if progress < self.edge.ancestor.latency: + self.status = "sending" + elif progress < self.edge.ancestor.latency + self.edge.edge_latency: + self.status = "transferring" + elif progress < self.edge.ovr_latency: + self.status = "receiving" + else: + self.status = "received" + class Architecture(Type): edges: Edges = Property( @@ -416,7 +470,7 @@ def density(self): """Returns the density of the graph, ie. the proportion of possible edges between nodes that exist in the graph""" num_nodes = len(self.all_nodes) - num_edges = len(self.edge_list) + num_edges = len(self.edges) architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) return architecture_density @@ -441,34 +495,19 @@ def __len__(self): @property def fully_propagated(self): - """Checks if all data and hypotheses that each node has have been transferred + """Checks if all data for each node have been transferred to its descendants. With zero latency, this should be the case after running self.propagate""" for node in self.all_nodes: for descendant in self.descendants(node): try: - if node.data_held and node.hypotheses_held: - if not (all(node.data_held[time] <= descendant.data_held[time] - for time in node.data_held) and - all(node.hypotheses_held[track][time] <= - descendant.hypotheses_held[track][time] - for track in node.hypotheses_held - for time in node.hypotheses_held[track])): - return False - elif node.data_held: - if not (all(node.data_held[time] <= descendant.data_held[time] - for time in node.data_held)): - return False - elif node.hypotheses_held: - if not all(node.hypotheses_held[track][time] <= - descendant.hypotheses_held[track][time] - for track in node.hypotheses_held - for time in node.hypotheses_held[track]): - return False - else: - # Node has no data, so has fully propagated - continue - except TypeError: # Should this be KeyError? + # 0 is for the actual data, not including arrival_time, which may be different + if not (all(node.data_held[status][time][0] <= + descendant.data_held[status][time][0] + for time in node.data_held) + for status in ["processed", "unprocessed"]): + return False + except TypeError: # Should this be KeyError? # descendant doesn't have all the keys node does return False return True @@ -486,7 +525,7 @@ def __init__(self, *args, **kwargs): raise TypeError("Information architecture should not contain any repeater nodes") def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.ndarray] = True, - **kwargs) -> Dict[SensorNode, Union[TrueDetection, Clutter]]: + **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ all_detections = dict() @@ -507,27 +546,21 @@ def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.nd for data in all_detections[sensor_node]: # The sensor acquires its own data instantly - sensor_node.update(self.current_time, data) + sensor_node.update(self.current_time, self.current_time, data) return all_detections - def propagate(self, time_increment: float, failed_edges: Collection = []): + def propagate(self, time_increment: float, failed_edges: Collection = None): """Performs the propagation of the measurements through the network""" - for node in self.all_nodes: - for descendant in self.descendants(node): - if (node, descendant) in failed_edges: - # The network architecture / some outside factor prevents information from node - # being transferred to other - continue - if node.data_held: - for time in node.data_held['unprocessed']: - for data in node.data_held['unprocessed'][time]: - # Send message to descendant here? - descendant.send_message(time, data) - - for node in self.processing_nodes: - node.update_messages() - node.process() + for edge in self.edges.edges: + if failed_edges and edge in failed_edges: + continue # in future, make it possible to simulate temporarily failed edges? + edge.update_messages(self.current_time) + for data, time_pertaining in edge.unsent_data: + edge.send_message(data, time_pertaining, self.current_time) + + # for node in self.processing_nodes: + # node.process() # This should happen when a new message is received if not self.fully_propagated: self.propagate(time_increment, failed_edges) return @@ -653,4 +686,3 @@ def _update_track(track, state, time): return track.append(state) - diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 9b4de242e..986bdc01b 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -1,5 +1,6 @@ from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ - CombinedArchitecture, FusionNode, RepeaterNode, SensorNode, Node, SensorFusionNode, Edge, Edges + CombinedArchitecture, FusionNode, RepeaterNode, SensorNode, Node, SensorFusionNode, Edge, \ + Edges, Message, _dict_set from ...sensor.base import PlatformMountable from stonesoup.models.measurement.categorical import MarkovianMeasurementModel @@ -14,6 +15,8 @@ from stonesoup.dataassociator.neighbour import GNNWith2DAssignment from stonesoup.initiator.categorical import SimpleCategoricalMeasurementInitiator from stonesoup.deleter.time import UpdateTimeStepsDeleter +from stonesoup.tracker.simple import SingleTargetTracker +from stonesoup.tracker.tests.conftest import detector # a useful fixture from datetime import datetime, timedelta import numpy as np @@ -21,6 +24,7 @@ import pytest + @pytest.fixture def params(): transition_matrix = np.array([[0.8, 0.2], # P(bike | bike), P(bike | car) @@ -66,21 +70,36 @@ def params(): prior = CategoricalState([1 / 2, 1 / 2], categories=hidden_classes) initiator = SimpleCategoricalMeasurementInitiator(prior_state=prior, updater=updater) deleter = UpdateTimeStepsDeleter(2) - - nodes = SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor), SensorNode(hmm_sensor), \ - SensorNode(hmm_sensor), FusionNode(predictor=predictor, - updater=updater, hypothesiser=hypothesiser, - data_associator=data_associator, - initiator=initiator, deleter=deleter) - - return {"small_nodes":nodes, "ground_truths":ground_truths} # big nodes cna be added - - -def test_info_architecure_propagation(params): + tracker = SingleTargetTracker(initiator, deleter, detector, data_associator, updater) # Needs to be filled in + + nodes = SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor), SensorNode(hmm_sensor),\ + SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor) + lat_nodes = SensorNode(sensor=hmm_sensor, latency=0.3), SensorNode(sensor=hmm_sensor, latency=0.8975), SensorNode(hmm_sensor, latency=0.356),\ + SensorNode(sensor=hmm_sensor, latency=0.7), SensorNode(sensor=hmm_sensor, latency=0.5) + big_nodes = SensorFusionNode(sensor=hmm_sensor, + predictor=predictor, + updater=updater, + hypothesiser=hypothesiser, + data_associator=data_associator, + initiator=initiator, + deleter=deleter, + tracker=tracker), \ + FusionNode(predictor=predictor, + updater=updater, + hypothesiser=hypothesiser, + data_associator=data_associator, + initiator=initiator, + deleter=deleter, + tracker=tracker) + return {"small_nodes":nodes, "big_nodes": big_nodes, "ground_truths": ground_truths, "lat_nodes": lat_nodes} + + +def test_info_architecture_propagation(params): """Is information correctly propagated through the architecture?""" nodes = params['small_nodes'] ground_truths = params['ground_truths'] - print(ground_truths) + lat_nodes = params['lat_nodes'] + # A "Y" shape, with data travelling "up" the Y # First, with no latency edges = Edges([Edge((nodes[0], nodes[1])), Edge((nodes[1], nodes[2])), @@ -90,15 +109,75 @@ def test_info_architecure_propagation(params): for _ in range(10): architecture.measure(ground_truths, noise=True) architecture.propagate(time_increment=1, failed_edges=[]) + # Check all nodes hold 10 times with data pertaining, all in "unprocessed" + #print(nodes[0].data_held['unprocessed'].keys()) + #print(len(nodes[0].data_held['unprocessed'].keys())) + assert len(nodes[0].data_held['unprocessed']) == 10 + assert len(nodes[0].data_held['processed']) == 0 + # Check each time has exactly 1 piece of data + testin = list(nodes[0].data_held['unprocessed'].values())[0] + print(len(testin), "\n"*10) + print(testin) + assert all(len(nodes[0].data_held['unprocessed'][time]) == 1 + for time in nodes[0].data_held['unprocessed']) + # Check node 1 holds 20 messages, and its descendants (2 and 3) hold 30 each + assert len(nodes[1].data_held['unprocessed']) == 10 + assert len(nodes[2].data_held['unprocessed']) == 10 + assert len(nodes[3].data_held['unprocessed']) == 10 + # Check that the data held by node 0 is a subset of that of node 1, and so on for node 1 with + # its own descendants. Note this will only work with zero latency. + assert nodes[0].data_held['unprocessed'].keys() <= nodes[1].data_held['unprocessed'].keys() + assert nodes[1].data_held['unprocessed'].keys() <= nodes[2].data_held['unprocessed'].keys() \ + and nodes[1].data_held['unprocessed'].keys() <= nodes[3].data_held['unprocessed'].keys() + + # Architecture with latency (Same network as before) + edges_w_latency = Edges([Edge((lat_nodes[0], lat_nodes[1]), edge_latency=0.2), Edge((lat_nodes[1], lat_nodes[2]), edge_latency=0.5), + Edge((lat_nodes[1], lat_nodes[3]), edge_latency=0.3465234565634)]) + lat_architecture = InformationArchitecture(edges=edges_w_latency) + for _ in range(10): + lat_architecture.measure(ground_truths, noise=True) + lat_architecture.propagate(time_increment=1, failed_edges=[]) + assert len(nodes[0].data_held) < len(nodes[1].data_held) + # Check node 1 holds fewer messages than both its ancestors + assert len(nodes[1].data_held) < (len(nodes[2].data_held) and len(nodes[3].data_held)) + + #Error Tests + #Test descendants() + with pytest.raises(ValueError): + architecture.descendants(nodes[4]) - assert something # Need to sort out issue with hypotheses_held + #Test send_message() + with pytest.raises(TypeError): + data = float(1.23456) + the_time_is = datetime.now() + Edge((nodes[0], nodes[4])).send_message(time_sent=the_time_is, data=data) + #Test update_message() + with pytest.raises(TypeError): + edge1 = Edge((nodes[0], nodes[4])) + data = float(1.23456) + the_time_is = datetime.now() + message = Message(data, nodes[0], nodes[4]) + _, edge1.messages_held = _dict_set(edge1.messages_held, message, 'pending', the_time_is) + edge1.update_messages(the_time_is) def test_info_architecture_fusion(params): """Are Fusion/SensorFusionNodes correctly fusing information together. (Currently they won't be)""" nodes = params['small_nodes'] - edges = Edges([Edge((nodes[0], nodes[2])), Edge((nodes[1], nodes[2]))]) + big_nodes = params['big_nodes'] + ground_truths = params['ground_truths'] + + # A "Y" shape, with data travelling "down" the Y from 2 sensor nodes into one fusion node, and onto a SensorFusion node + edges = Edges([Edge((nodes[0], big_nodes[1])), Edge((nodes[1], big_nodes[1])), Edge((big_nodes[1], big_nodes[0]))]) + architecture = InformationArchitecture(edges=edges) + + for _ in range(10): + architecture.measure(ground_truths, noise=True) + architecture.propagate(time_increment=1, failed_edges=[]) + + assert something #to check data has been fused correctly at big_node[1] and big_node[0] + def test_information_architecture_using_hmm(): From a2484b9a2e7224c00dce3f25aaa8385e121f3921 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 28 Mar 2023 17:32:13 +0100 Subject: [PATCH 028/170] bug fixing towards correct measurement --- stonesoup/types/architecture.py | 24 ++++++++++++++-------- stonesoup/types/groundtruth.py | 9 ++++++++ stonesoup/types/tests/test_architecture.py | 17 ++++++++------- stonesoup/types/tests/test_groundtruth.py | 16 +++++++++++++++ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 79914d432..0725cd9e6 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -2,7 +2,7 @@ from ..base import Property, Base from .base import Type from ..sensor.sensor import Sensor -from ..types.groundtruth import GroundTruthState +from ..types.groundtruth import GroundTruthPath from ..types.detection import TrueDetection, Clutter, Detection from ..types.hypothesis import Hypothesis from ..types.track import Track @@ -334,9 +334,9 @@ class Architecture(Type): doc="An Edges object containing all edges. For A to be connected to B we would have an " "Edge with edge_pair=(A, B) in this object.") current_time: datetime = Property( - default=None, doc="The time which the instance is at for the purpose of simulation. " - "This is increased by the propagate method, and defaults to the current system time") + "This is increased by the propagate method. This should be set to the earliest timestep " + "from the ground truth") name: str = Property( default=None, doc="A name for the architecture, to be used to name files and/or title plots. Default is " @@ -524,15 +524,22 @@ def __init__(self, *args, **kwargs): if isinstance(node, RepeaterNode): raise TypeError("Information architecture should not contain any repeater nodes") - def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.ndarray] = True, + def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ all_detections = dict() - for sensor_node in self.sensor_nodes: + # Get rid of ground truths that have not yet happened + # (ie GroundTruthState's with timestamp after self.current_time) + new_ground_truths = [] + for ground_truth_path in ground_truths: + new_ground_truths.append(ground_truth_path.available_at_time(self.current_time)) - all_detections[sensor_node] = sensor_node.sensor.measure(ground_truths, noise, - **kwargs) + for sensor_node in self.sensor_nodes: + all_detections[sensor_node] = set() + for states in np.vstack(new_ground_truths).T: + for detection in sensor_node.sensor.measure(states, noise, **kwargs): + all_detections[sensor_node].add(detection) # Borrowed below from SensorSuite. I don't think it's necessary, but might be something # we need. If so, will need to define self.attributes_inform @@ -545,8 +552,9 @@ def measure(self, ground_truths: Set[GroundTruthState], noise: Union[bool, np.nd # detection.metadata.update(attributes_dict) for data in all_detections[sensor_node]: + print(data.timestamp, self.current_time) # The sensor acquires its own data instantly - sensor_node.update(self.current_time, self.current_time, data) + sensor_node.update(data.timestamp, data.timestamp, data) return all_detections diff --git a/stonesoup/types/groundtruth.py b/stonesoup/types/groundtruth.py index 0639941f8..adc32b5d5 100644 --- a/stonesoup/types/groundtruth.py +++ b/stonesoup/types/groundtruth.py @@ -4,6 +4,8 @@ from .state import State, StateMutableSequence, CategoricalState, CompositeState from ..base import Property +from datetime import datetime + class GroundTruthState(State): """Ground Truth State type""" @@ -40,6 +42,13 @@ def __init__(self, *args, **kwargs): if self.id is None: self.id = str(uuid.uuid4()) + def available_at_time(self, time: datetime): + new_path = [] + for state in self.states: + if state.timestamp <= time: + new_path.append(state) + return GroundTruthPath(new_path, self.id) + class CompositeGroundTruthState(CompositeState): """Composite ground truth state type. diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 986bdc01b..4e0df9c11 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -91,7 +91,8 @@ def params(): initiator=initiator, deleter=deleter, tracker=tracker) - return {"small_nodes":nodes, "big_nodes": big_nodes, "ground_truths": ground_truths, "lat_nodes": lat_nodes} + return {"small_nodes": nodes, "big_nodes": big_nodes, "ground_truths": ground_truths, "lat_nodes": lat_nodes, + "start": start} def test_info_architecture_propagation(params): @@ -99,25 +100,27 @@ def test_info_architecture_propagation(params): nodes = params['small_nodes'] ground_truths = params['ground_truths'] lat_nodes = params['lat_nodes'] + start = params['start'] # A "Y" shape, with data travelling "up" the Y # First, with no latency edges = Edges([Edge((nodes[0], nodes[1])), Edge((nodes[1], nodes[2])), Edge((nodes[1], nodes[3]))]) - architecture = InformationArchitecture(edges=edges) + architecture = InformationArchitecture(edges=edges, current_time=start) - for _ in range(10): + for _ in range(11): architecture.measure(ground_truths, noise=True) - architecture.propagate(time_increment=1, failed_edges=[]) + architecture.propagate(time_increment=1.0, failed_edges=[]) + #print(f"length of data_held: {len(nodes[0].data_held['unprocessed'][architecture.current_time])}") # Check all nodes hold 10 times with data pertaining, all in "unprocessed" #print(nodes[0].data_held['unprocessed'].keys()) - #print(len(nodes[0].data_held['unprocessed'].keys())) + print(f"should be 11 keys in this: {len(nodes[0].data_held['unprocessed'].keys())}") assert len(nodes[0].data_held['unprocessed']) == 10 assert len(nodes[0].data_held['processed']) == 0 # Check each time has exactly 1 piece of data testin = list(nodes[0].data_held['unprocessed'].values())[0] - print(len(testin), "\n"*10) - print(testin) + #print(len(testin), "\n"*10) + #print(testin) assert all(len(nodes[0].data_held['unprocessed'][time]) == 1 for time in nodes[0].data_held['unprocessed']) # Check node 1 holds 20 messages, and its descendants (2 and 3) hold 30 each diff --git a/stonesoup/types/tests/test_groundtruth.py b/stonesoup/types/tests/test_groundtruth.py index 1d21b24b9..f264eb5c6 100644 --- a/stonesoup/types/tests/test_groundtruth.py +++ b/stonesoup/types/tests/test_groundtruth.py @@ -3,6 +3,7 @@ from ..groundtruth import GroundTruthState, GroundTruthPath, CategoricalGroundTruthState, \ CompositeGroundTruthState +from datetime import datetime def test_groundtruthpath(): empty_path = GroundTruthPath() @@ -36,3 +37,18 @@ def test_composite_groundtruth(): sub_state3 = CategoricalGroundTruthState([0.6, 0.4], metadata={'shape': 'square'}) state = CompositeGroundTruthState(sub_states=[sub_state1, sub_state2, sub_state3]) assert state.metadata == {'colour': 'red', 'speed': 'fast', 'shape': 'square'} + + +def test_available_at_time(): + state_1 = GroundTruthState(np.array([[1]]), timestamp=datetime(2023, 3, 28, 16, 54, 1, 868643)) + state_2 = GroundTruthState(np.array([[1]]), timestamp=datetime(2023, 3, 28, 16, 54, 2, 868643)) + state_3 = GroundTruthState(np.array([[1]]), timestamp=datetime(2023, 3, 28, 16, 54, 3, 868643)) + path = GroundTruthPath([state_1, state_2, state_3]) + path_0 = path.available_at_time(datetime(2023, 3, 28, 16, 54, 0, 868643)) + assert len(path_0) == 0 + path_2 = path.available_at_time(datetime(2023, 3, 28, 16, 54, 2, 868643)) + assert len(path_2) == 2 + path_2_1 = path.available_at_time(datetime(2023, 3, 28, 16, 54, 2, 999643)) + assert len(path_2_1) == 2 + path_3 = path.available_at_time(datetime(2023, 3, 28, 16, 54, 20, 868643)) + assert len(path_3) == 3 From e6fb87b12f719ea4a38b47a6c9d7e3d9f85d0a42 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Wed, 5 Apr 2023 11:41:08 +0100 Subject: [PATCH 029/170] bug fixing towards correct measurement again --- stonesoup/types/architecture.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 0725cd9e6..388b51d7e 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -496,8 +496,7 @@ def __len__(self): @property def fully_propagated(self): """Checks if all data for each node have been transferred - to its descendants. - With zero latency, this should be the case after running self.propagate""" + to its descendants. With zero latency, this should be the case after running propagate""" for node in self.all_nodes: for descendant in self.descendants(node): try: @@ -531,14 +530,16 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # Get rid of ground truths that have not yet happened # (ie GroundTruthState's with timestamp after self.current_time) - new_ground_truths = [] + new_ground_truths = set() for ground_truth_path in ground_truths: - new_ground_truths.append(ground_truth_path.available_at_time(self.current_time)) + # need an if len(states) == 0 continue condition here? + new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) + + print("NEW GROUND TRUTHS LEN", len(new_ground_truths)) for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() - for states in np.vstack(new_ground_truths).T: - for detection in sensor_node.sensor.measure(states, noise, **kwargs): + for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): all_detections[sensor_node].add(detection) # Borrowed below from SensorSuite. I don't think it's necessary, but might be something @@ -552,7 +553,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # detection.metadata.update(attributes_dict) for data in all_detections[sensor_node]: - print(data.timestamp, self.current_time) + #print(data.timestamp, self.current_time) # The sensor acquires its own data instantly sensor_node.update(data.timestamp, data.timestamp, data) @@ -569,9 +570,13 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): # for node in self.processing_nodes: # node.process() # This should happen when a new message is received + count = 0 if not self.fully_propagated: + count += 1 + print(count) self.propagate(time_increment, failed_edges) return + print(f"\n\n done \n\n") self.current_time += timedelta(seconds=time_increment) From 6ccd89ca1ab2119fb70a10d3fa453de6d21f0833 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Mon, 17 Apr 2023 12:52:07 +0100 Subject: [PATCH 030/170] bug fixin --- stonesoup/types/architecture.py | 51 +++++++++------- stonesoup/types/tests/test_architecture.py | 67 ++++++++++++++++------ 2 files changed, 80 insertions(+), 38 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 388b51d7e..159cb254f 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -15,7 +15,6 @@ from ..tracker.base import Tracker from typing import List, Collection, Tuple, Set, Union, Dict -from itertools import product import numpy as np import networkx as nx import graphviz @@ -54,12 +53,12 @@ def update(self, time_pertaining, time_arrived, data, track=None): raise TypeError("Data provided without accompanying Track must be a Detection or " "a Track") added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], - (data, time_arrived), time_pertaining) + DataPiece(data, time_arrived, False), time_pertaining) else: if not isinstance(data, Hypothesis): raise TypeError("Data provided with Track must be a Hypothesis") added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], - (data, time_arrived, track), + DataPiece(data, time_arrived, False, track), time_pertaining) return added @@ -180,6 +179,23 @@ def __init__(self, *args, **kwargs): self.shape = 'circle' +class DataPiece(Type): + """A piece of data for use in an architecture. Sent via Messages, and stored in a Node's data_held""" + node: Node = Property( + doc="The Node this datapiece belongs to") + data: Union[Detection, Track, Hypothesis] = Property( + doc="A Detection, Track, or Hypothesis") + time_arrived: datetime = Property( + doc="The time at which this piece of data was received by the Node, either by Message or by sensing.") + track: Track = Property( + doc="The Track in the event of data being a Hypothesis", + default=None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sent_to = set() # all Nodes the data_piece has been sent to, to avoid duplicates + + class Edge(Type): """Comprised of two connected Nodes""" nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (ancestor, descendant") @@ -191,13 +207,15 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.messages_held = {"pending": {}, "received": {}} - def send_message(self, data, time_pertaining, time_sent): - if not isinstance(data, (Detection, Hypothesis, Track)): + def send_message(self, data_piece, time_pertaining, time_sent): + if not isinstance(data_piece, DataPiece): raise TypeError("Message info must be one of the following types: " "Detection, Hypothesis or Track") # Add message to 'pending' dict of edge - message = Message(self, time_pertaining, time_sent, data) + message = Message(self, time_pertaining, time_sent, data_piece) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) + # change sent to True + data_piece.sent_to.add(self.nodes[1]) def update_messages(self, current_time): # Check info type is what we expect @@ -237,20 +255,11 @@ def unsent_data(self): """Data held by the ancestor that has not been sent to the descendant. Current implementation should work but gross""" unsent = [] - fudge = [] # this is a horrific way of doing it. To fix later - for comb in product(["unprocessed", "processed"], repeat=2): - for time_pertaining in self.ancestor.data_held[comb[0]]: - for data, _ in self.ancestor.data_held[comb[0]][time_pertaining]: - if time_pertaining not in self.descendant.data_held[comb[1]]: - if (data, time_pertaining) in fudge: - unsent.append((data, time_pertaining)) - else: - fudge.append((data, time_pertaining)) - elif data not in self.descendant.data_held[comb[1]][time_pertaining]: - if (data, time_pertaining) in fudge: - unsent.append((data, time_pertaining)) - else: - fudge.append((data, time_pertaining)) + for status in ["unprocessed", "processed"]: + for time_pertaining in self.ancestor.data_held[status]: + for data_piece in self.ancestor.data_held[status][time_pertaining]: + if self.descendant not in data_piece.sent_to: + unsent.append((data_piece, time_pertaining)) return unsent @@ -297,7 +306,7 @@ class Message(Type): time_sent: datetime = Property( doc="Time at which the message was sent", default=None) - data: Union[Track, Hypothesis, Detection] = Property( + data_piece: DataPiece = Property( doc="Info that the sent message contains", default=None) diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 4e0df9c11..14ab5d2c5 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -111,31 +111,64 @@ def test_info_architecture_propagation(params): for _ in range(11): architecture.measure(ground_truths, noise=True) architecture.propagate(time_increment=1.0, failed_edges=[]) - #print(f"length of data_held: {len(nodes[0].data_held['unprocessed'][architecture.current_time])}") + architecture.propagate(time_increment=5.0, failed_edges=[]) + architecture.propagate(time_increment=5.0, failed_edges=[]) + architecture.propagate(time_increment=5.0, failed_edges=[]) + # print(f"length of data_held: {len(nodes[0].data_held['unprocessed'][architecture.current_time])}") # Check all nodes hold 10 times with data pertaining, all in "unprocessed" - #print(nodes[0].data_held['unprocessed'].keys()) + # print(nodes[0].data_held['unprocessed'].keys()) print(f"should be 11 keys in this: {len(nodes[0].data_held['unprocessed'].keys())}") - assert len(nodes[0].data_held['unprocessed']) == 10 + assert len(nodes[0].data_held['unprocessed']) == 11 assert len(nodes[0].data_held['processed']) == 0 - # Check each time has exactly 1 piece of data - testin = list(nodes[0].data_held['unprocessed'].values())[0] - #print(len(testin), "\n"*10) - #print(testin) + # Check each time has exactly 3 pieces of data, one for each target + # print(f"Is this a set? {type(list(nodes[0].data_held['unprocessed'].values())[0])}") + # testin = list(list(nodes[0].data_held['unprocessed'].values())[0]) + # print(len(testin), "\n"*10) + # bleh = testin[0] + # blah = testin[1] + # print(f"Are they the same? {bleh == blah}") + # print(f"Same timestamp? {bleh[1] == blah[1]} and {bleh[0].timestamp == blah[0].timestamp}") + # print(f"Same detection? {bleh[0] == blah[0]}") + # print(bleh) + # print("\n\n\n and \n\n\n") + # print(blah) + # print("\n\n\n") + # blih = set() + # blih.add(bleh) + # blih.add(blah) + # bleeh = {bleh, blah} + # print(f"The length of the set of two equal things is {len(bleeh)}") + for time in nodes[1].data_held['unprocessed']: + print("\n\n\n\n ENTRY \n\n\n\n") + print(len(nodes[1].data_held['unprocessed'][time])) + # print(nodes[0].data_held['unprocessed']) assert all(len(nodes[0].data_held['unprocessed'][time]) == 1 for time in nodes[0].data_held['unprocessed']) # Check node 1 holds 20 messages, and its descendants (2 and 3) hold 30 each - assert len(nodes[1].data_held['unprocessed']) == 10 - assert len(nodes[2].data_held['unprocessed']) == 10 - assert len(nodes[3].data_held['unprocessed']) == 10 + assert len(nodes[1].data_held['unprocessed']) == 11 + assert len(nodes[2].data_held['unprocessed']) == 11 + assert len(nodes[3].data_held['unprocessed']) == 11 # Check that the data held by node 0 is a subset of that of node 1, and so on for node 1 with # its own descendants. Note this will only work with zero latency. assert nodes[0].data_held['unprocessed'].keys() <= nodes[1].data_held['unprocessed'].keys() assert nodes[1].data_held['unprocessed'].keys() <= nodes[2].data_held['unprocessed'].keys() \ - and nodes[1].data_held['unprocessed'].keys() <= nodes[3].data_held['unprocessed'].keys() + and nodes[1].data_held['unprocessed'].keys() <= nodes[3].data_held['unprocessed'].keys() + + duplicates = nodes[1].data_held['unprocessed'][list(nodes[1].data_held['unprocessed'].keys())[-1]] + for det in duplicates: + print(hash(det)) + # print(nodes[1].data_held['unprocessed'][list(nodes[1].data_held['unprocessed'].keys())[-1]]) + assert all(len(nodes[1].data_held['unprocessed'][time]) == 2 + for time in nodes[1].data_held['unprocessed']) + for time in nodes[2].data_held['unprocessed']: + print(len(nodes[2].data_held['unprocessed'][time])) + assert all(len(nodes[2].data_held['unprocessed'][time]) == 3 + for time in nodes[2].data_held['unprocessed']) # Architecture with latency (Same network as before) - edges_w_latency = Edges([Edge((lat_nodes[0], lat_nodes[1]), edge_latency=0.2), Edge((lat_nodes[1], lat_nodes[2]), edge_latency=0.5), - Edge((lat_nodes[1], lat_nodes[3]), edge_latency=0.3465234565634)]) + edges_w_latency = Edges( + [Edge((lat_nodes[0], lat_nodes[1]), edge_latency=0.2), Edge((lat_nodes[1], lat_nodes[2]), edge_latency=0.5), + Edge((lat_nodes[1], lat_nodes[3]), edge_latency=0.3465234565634)]) lat_architecture = InformationArchitecture(edges=edges_w_latency) for _ in range(10): lat_architecture.measure(ground_truths, noise=True) @@ -144,18 +177,18 @@ def test_info_architecture_propagation(params): # Check node 1 holds fewer messages than both its ancestors assert len(nodes[1].data_held) < (len(nodes[2].data_held) and len(nodes[3].data_held)) - #Error Tests - #Test descendants() + # Error Tests + # Test descendants() with pytest.raises(ValueError): architecture.descendants(nodes[4]) - #Test send_message() + # Test send_message() with pytest.raises(TypeError): data = float(1.23456) the_time_is = datetime.now() Edge((nodes[0], nodes[4])).send_message(time_sent=the_time_is, data=data) - #Test update_message() + # Test update_message() with pytest.raises(TypeError): edge1 = Edge((nodes[0], nodes[4])) data = float(1.23456) From 8630329e4b4de177b7eaea7fa26d1924148ad473 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Mon, 17 Apr 2023 17:29:04 +0100 Subject: [PATCH 031/170] propagation now works fully without latency --- stonesoup/types/architecture.py | 65 ++++++++++------------ stonesoup/types/tests/test_architecture.py | 2 +- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 159cb254f..46dbbcd10 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -45,20 +45,23 @@ def __init__(self, *args, **kwargs): self.data_held = {"processed": {}, "unprocessed": {}} # Node no longer handles messages. All done by Edge - def update(self, time_pertaining, time_arrived, data, track=None): + def update(self, time_pertaining, time_arrived, data_piece, track=None): if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): raise TypeError("Times must be datetime objects") if not track: - if not isinstance(data, Detection) and not isinstance(data, Track): - raise TypeError("Data provided without accompanying Track must be a Detection or " - "a Track") + if not isinstance(data_piece.data, Detection) and not isinstance(data_piece.data, Track): + raise TypeError(f"Data provided without accompanying Track must be a Detection or a Track, not a " + f"{type(data_piece.data).__name__}") added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], - DataPiece(data, time_arrived, False), time_pertaining) + DataPiece(self, data_piece.originator, data_piece.data, + time_arrived), + time_pertaining) else: - if not isinstance(data, Hypothesis): + if not isinstance(data_piece.data, Hypothesis): raise TypeError("Data provided with Track must be a Hypothesis") added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], - DataPiece(data, time_arrived, False, track), + DataPiece(self, data_piece.originator, data_piece.data, + time_arrived, track), time_pertaining) return added @@ -162,9 +165,9 @@ class SensorFusionNode(SensorNode, FusionNode): """A node that is both a sensor and also processes data""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating - if not self.shape: + if self.colour in ['#006400', '#1f77b4']: + self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating + if self.shape in ['square', 'hexagon']: self.shape = 'octagon' @@ -182,7 +185,10 @@ def __init__(self, *args, **kwargs): class DataPiece(Type): """A piece of data for use in an architecture. Sent via Messages, and stored in a Node's data_held""" node: Node = Property( - doc="The Node this datapiece belongs to") + doc="The Node this data piece belongs to") + originator: Node = Property( + doc="The node which first created this data, ie by sensing or fusing information together. " + "If the data is simply passed along the chain, the originator remains unchanged. ") data: Union[Detection, Track, Hypothesis] = Property( doc="A Detection, Track, or Hypothesis") time_arrived: datetime = Property( @@ -214,12 +220,12 @@ def send_message(self, data_piece, time_pertaining, time_sent): # Add message to 'pending' dict of edge message = Message(self, time_pertaining, time_sent, data_piece) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) - # change sent to True + # ensure message not re-sent data_piece.sent_to.add(self.nodes[1]) def update_messages(self, current_time): # Check info type is what we expect - to_remove = set() # Needed as we can't change size of a set during iteration + to_remove = set() # Needed as we can't change size of a set during iteration for time in self.messages_held['pending']: for message in self.messages_held['pending'][time]: message.update(current_time) @@ -232,7 +238,7 @@ def update_messages(self, current_time): 'received', current_time) # Update message.recipient_node.update(message.time_pertaining, message.arrival_time, - message.data) + message.data_piece) for time, message in to_remove: self.messages_held['pending'][time].remove(message) @@ -506,19 +512,11 @@ def __len__(self): def fully_propagated(self): """Checks if all data for each node have been transferred to its descendants. With zero latency, this should be the case after running propagate""" - for node in self.all_nodes: - for descendant in self.descendants(node): - try: - # 0 is for the actual data, not including arrival_time, which may be different - if not (all(node.data_held[status][time][0] <= - descendant.data_held[status][time][0] - for time in node.data_held) - for status in ["processed", "unprocessed"]): - return False - except TypeError: # Should this be KeyError? - # descendant doesn't have all the keys node does - return False - return True + for edge in self.edges.edges: + if len(edge.unsent_data) != 0: + return False + + return True class InformationArchitecture(Architecture): @@ -544,8 +542,6 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # need an if len(states) == 0 continue condition here? new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) - print("NEW GROUND TRUTHS LEN", len(new_ground_truths)) - for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): @@ -564,7 +560,8 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd for data in all_detections[sensor_node]: #print(data.timestamp, self.current_time) # The sensor acquires its own data instantly - sensor_node.update(data.timestamp, data.timestamp, data) + sensor_node.update(data.timestamp, data.timestamp, DataPiece(sensor_node, sensor_node, data, + data.timestamp)) return all_detections @@ -574,18 +571,16 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): if failed_edges and edge in failed_edges: continue # in future, make it possible to simulate temporarily failed edges? edge.update_messages(self.current_time) - for data, time_pertaining in edge.unsent_data: - edge.send_message(data, time_pertaining, self.current_time) + for data_piece, time_pertaining in edge.unsent_data: + edge.send_message(data_piece, time_pertaining, self.current_time) # for node in self.processing_nodes: # node.process() # This should happen when a new message is received count = 0 if not self.fully_propagated: count += 1 - print(count) self.propagate(time_increment, failed_edges) return - print(f"\n\n done \n\n") self.current_time += timedelta(seconds=time_increment) @@ -683,7 +678,7 @@ def _dict_set(my_dict, value, key1, key2=None): my_dict = {key1: {value}} elif key2: if key1 in my_dict: - if key2 in my_dict: + if key2 in my_dict[key1]: old_len = len(my_dict[key1][key2]) my_dict[key1][key2].add(value) return len(my_dict[key1][key2]) == old_len + 1, my_dict diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py index 14ab5d2c5..3acf66e42 100644 --- a/stonesoup/types/tests/test_architecture.py +++ b/stonesoup/types/tests/test_architecture.py @@ -34,7 +34,7 @@ def params(): hidden_classes = ['bike', 'car'] ground_truths = list() - for i in range(1, 4): # 4 targets + for i in range(1, 1): # 4 targets state_vector = np.zeros(2) # create a vector with 2 zeroes state_vector[ np.random.choice(2, 1, p=[1 / 2, 1 / 2])] = 1 # pick a random class out of the 2 From a07cf0f45cb42b23e39f2f5af308c6cde5c2333a Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 2 May 2023 11:55:26 +0100 Subject: [PATCH 032/170] bug fixin --- stonesoup/types/architecture.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 46dbbcd10..92294f0bf 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -13,6 +13,7 @@ from ..initiator.base import Initiator from ..deleter.base import Deleter from ..tracker.base import Tracker +from ..types.time import TimeRange from typing import List, Collection, Tuple, Set, Union, Dict import numpy as np @@ -211,7 +212,9 @@ class Edge(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.messages_held = {"pending": {}, "received": {}} + self.messages_held = {"pending": {}, # For pending, messages indexed by time sent. + "received": {}} # For received, by time received + self.time_ranges_failed = [] # List of time ranges during which this edge was failed def send_message(self, data_piece, time_pertaining, time_sent): if not isinstance(data_piece, DataPiece): @@ -231,11 +234,10 @@ def update_messages(self, current_time): message.update(current_time) if message.status == 'received': # Then the latency has passed and message has been received - # No need for a Node to hold messages # Move message from pending to received messages in edge to_remove.add((time, message)) _, self.messages_held = _dict_set(self.messages_held, message, - 'received', current_time) + 'received', message.arrival_time) # Update message.recipient_node.update(message.time_pertaining, message.arrival_time, message.data_piece) @@ -243,6 +245,11 @@ def update_messages(self, current_time): for time, message in to_remove: self.messages_held['pending'][time].remove(message) + def _failed(self, current_time, duration): + """Keeps track of when this edge was failed using the time_ranges_failed property. """ + end_time = current_time + timedelta(duration) + self.time_ranges_failed.append(TimeRange(current_time, end_time)) + @property def ancestor(self): return self.nodes[0] @@ -258,8 +265,7 @@ def ovr_latency(self): @property def unsent_data(self): - """Data held by the ancestor that has not been sent to the descendant. - Current implementation should work but gross""" + """Data held by the ancestor that has not been sent to the descendant.""" unsent = [] for status in ["unprocessed", "processed"]: for time_pertaining in self.ancestor.data_held[status]: @@ -330,6 +336,7 @@ def recipient_node(self): @property def arrival_time(self): + # TODO: incorporate failed time ranges here. Not essential for a first PR. Could do with merging of PR #664 return self.time_sent + timedelta(seconds=self.edge.ovr_latency) def update(self, current_time): @@ -545,7 +552,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): - all_detections[sensor_node].add(detection) + all_detections[sensor_node].add(detection) # Borrowed below from SensorSuite. I don't think it's necessary, but might be something # we need. If so, will need to define self.attributes_inform @@ -558,7 +565,6 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # detection.metadata.update(attributes_dict) for data in all_detections[sensor_node]: - #print(data.timestamp, self.current_time) # The sensor acquires its own data instantly sensor_node.update(data.timestamp, data.timestamp, DataPiece(sensor_node, sensor_node, data, data.timestamp)) @@ -569,10 +575,11 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): """Performs the propagation of the measurements through the network""" for edge in self.edges.edges: if failed_edges and edge in failed_edges: - continue # in future, make it possible to simulate temporarily failed edges? + edge._failed(self.current_time, time_increment) + continue # No data passed along these edges edge.update_messages(self.current_time) for data_piece, time_pertaining in edge.unsent_data: - edge.send_message(data_piece, time_pertaining, self.current_time) + edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) # for node in self.processing_nodes: # node.process() # This should happen when a new message is received From b4127164850312902b9bfbcacba4097c6d5a8402 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 2 May 2023 12:32:08 +0100 Subject: [PATCH 033/170] data is passed only along direct edges --- stonesoup/types/architecture.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index 92294f0bf..debd9b5f0 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -43,24 +43,24 @@ class Node(Type): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.data_held = {"processed": {}, "unprocessed": {}} + self.data_held = {"fused": {}, "created": {}, "unfused": {}} # Node no longer handles messages. All done by Edge - def update(self, time_pertaining, time_arrived, data_piece, track=None): + def update(self, time_pertaining, time_arrived, data_piece, category, track=None): if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): raise TypeError("Times must be datetime objects") if not track: if not isinstance(data_piece.data, Detection) and not isinstance(data_piece.data, Track): raise TypeError(f"Data provided without accompanying Track must be a Detection or a Track, not a " f"{type(data_piece.data).__name__}") - added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], + added, self.data_held[category] = _dict_set(self.data_held[category], DataPiece(self, data_piece.originator, data_piece.data, time_arrived), time_pertaining) else: if not isinstance(data_piece.data, Hypothesis): raise TypeError("Data provided with Track must be a Hypothesis") - added, self.data_held['unprocessed'] = _dict_set(self.data_held['unprocessed'], + added, self.data_held[category] = _dict_set(self.data_held[category], DataPiece(self, data_piece.originator, data_piece.data, time_arrived, track), time_pertaining) @@ -240,7 +240,7 @@ def update_messages(self, current_time): 'received', message.arrival_time) # Update message.recipient_node.update(message.time_pertaining, message.arrival_time, - message.data_piece) + message.data_piece, "unfused") for time, message in to_remove: self.messages_held['pending'][time].remove(message) @@ -267,7 +267,7 @@ def ovr_latency(self): def unsent_data(self): """Data held by the ancestor that has not been sent to the descendant.""" unsent = [] - for status in ["unprocessed", "processed"]: + for status in ["fused", "created"]: for time_pertaining in self.ancestor.data_held[status]: for data_piece in self.ancestor.data_held[status][time_pertaining]: if self.descendant not in data_piece.sent_to: @@ -567,7 +567,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd for data in all_detections[sensor_node]: # The sensor acquires its own data instantly sensor_node.update(data.timestamp, data.timestamp, DataPiece(sensor_node, sensor_node, data, - data.timestamp)) + data.timestamp), "created") return all_detections From 0e95ad2a902c21becc806cd4f16ed75b64ae4966 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 2 May 2023 14:58:46 +0100 Subject: [PATCH 034/170] MultiTargetFusionTracker started --- stonesoup/tracker/fusion.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 stonesoup/tracker/fusion.py diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py new file mode 100644 index 000000000..c80c2ff82 --- /dev/null +++ b/stonesoup/tracker/fusion.py @@ -0,0 +1,4 @@ +from .base import Tracker + +class MultiTargetFusionTracker(Tracker): + """Takes Detections and Tracks, and fuses them in the style of MultiTargetTracker""" \ No newline at end of file From 99c879ca431e01467391d78fe3eecc613ede5d03 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Thu, 4 May 2023 13:58:50 +0100 Subject: [PATCH 035/170] Debugging with latency successful --- stonesoup/types/architecture.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py index debd9b5f0..036d2048d 100644 --- a/stonesoup/types/architecture.py +++ b/stonesoup/types/architecture.py @@ -316,11 +316,9 @@ class Message(Type): "Different from time_sent when data is passed on that was not generated by this " "Node's ancestor") time_sent: datetime = Property( - doc="Time at which the message was sent", - default=None) + doc="Time at which the message was sent") data_piece: DataPiece = Property( - doc="Info that the sent message contains", - default=None) + doc="Info that the sent message contains") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -578,6 +576,7 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): edge._failed(self.current_time, time_increment) continue # No data passed along these edges edge.update_messages(self.current_time) + # fuse goes here? for data_piece, time_pertaining in edge.unsent_data: edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) From 79bdc7a48093ce3a7fe5e7c4eccc5624d13afbc2 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Fri, 12 May 2023 14:12:40 +0100 Subject: [PATCH 036/170] Move into new architecture sub-module. Begin implementation of threading --- stonesoup/architecture/architecture.py | 318 +++++++++++++++++++++++++ stonesoup/architecture/edge.py | 192 +++++++++++++++ stonesoup/architecture/node.py | 140 +++++++++++ 3 files changed, 650 insertions(+) create mode 100644 stonesoup/architecture/architecture.py create mode 100644 stonesoup/architecture/edge.py create mode 100644 stonesoup/architecture/node.py diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py new file mode 100644 index 000000000..cfa9491a8 --- /dev/null +++ b/stonesoup/architecture/architecture.py @@ -0,0 +1,318 @@ +from abc import abstractmethod +from ..base import Base, Property +from .node import Node, FusionNode, SensorNode, RepeaterNode +from .edge import Edge, Edges, DataPiece +from ..types.groundtruth import GroundTruthPath +from ..types.detection import TrueDetection, Detection, Clutter + +from typing import List, Collection, Tuple, Set, Union, Dict +import numpy as np +import networkx as nx +import graphviz +from string import ascii_uppercase as auc +from datetime import datetime, timedelta +import threading + +class Architecture(Base): + edges: Edges = Property( + doc="An Edges object containing all edges. For A to be connected to B we would have an " + "Edge with edge_pair=(A, B) in this object.") + current_time: datetime = Property( + doc="The time which the instance is at for the purpose of simulation. " + "This is increased by the propagate method. This should be set to the earliest timestep " + "from the ground truth") + name: str = Property( + default=None, + doc="A name for the architecture, to be used to name files and/or title plots. Default is " + "the class name") + force_connected: bool = Property( + default=True, + doc="If True, the undirected version of the graph must be connected, ie. all nodes should " + "be connected via some path. Set this to False to allow an unconnected architecture. " + "Default is True") + font_size: int = Property( + default=8, + doc='Font size for node labels') + node_dim: tuple = Property( + default=(0.5, 0.5), + doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.name: + self.name = type(self).__name__ + if not self.current_time: + self.current_time = datetime.now() + + self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) + + if self.force_connected and not self.is_connected and len(self) > 0: + raise ValueError("The graph is not connected. Use force_connected=False, " + "if you wish to override this requirement") + + # Set attributes such as label, colour, shape, etc for each node + last_letters = {'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', + 'RepeaterNode': ''} + for node in self.di_graph.nodes: + if node.label: + label = node.label + else: + label, last_letters = _default_label(node, last_letters) + node.label = label + attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", + "fontsize": f"{self.font_size}", "width": f"{self.node_dim[0]}", + "height": f"{self.node_dim[1]}", "fixedsize": True} + self.di_graph.nodes[node].update(attr) + + def descendants(self, node: Node): + """Returns a set of all nodes to which the input node has a direct edge to""" + if node not in self.all_nodes: + raise ValueError("Node not in this architecture") + descendants = set() + for other in self.all_nodes: + if (node, other) in self.edges.edge_list: + descendants.add(other) + return descendants + + def ancestors(self, node: Node): + """Returns a set of all nodes to which the input node has a direct edge from""" + if node not in self.all_nodes: + raise ValueError("Node not in this architecture") + ancestors = set() + for other in self.all_nodes: + if (other, node) in self.edges.edge_list: + ancestors.add(other) + return ancestors + + @abstractmethod + def propagate(self, time_increment: float): + raise NotImplementedError + + @property + def all_nodes(self): + return set(self.di_graph.nodes) + + @property + def sensor_nodes(self): + sensor_nodes = set() + for node in self.all_nodes: + if isinstance(node, SensorNode): + sensor_nodes.add(node) + return sensor_nodes + + @property + def fusion_nodes(self): + processing = set() + for node in self.all_nodes: + if isinstance(node, FusionNode): + processing.add(node) + return processing + + def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, + bgcolour="lightgray", node_style="filled"): + """Creates a pdf plot of the directed graph and displays it + + :param dir_path: The path to save the pdf and .gv files to + :param filename: Name to call the associated files + :param use_positions: + :param plot_title: If a string is supplied, makes this the title of the plot. If True, uses + the name attribute of the graph to title the plot. If False, no title is used. + Default is False + :param bgcolour: String containing the background colour for the plot. + Default is "lightgray". See graphviz attributes for more information. + One alternative is "white" + :param node_style: String containing the node style for the plot. + Default is "filled". See graphviz attributes for more information. + One alternative is "solid" + :return: + """ + if use_positions: + for node in self.di_graph.nodes: + if not isinstance(node.position, Tuple): + raise TypeError("If use_positions is set to True, every node must have a " + "position, given as a Tuple of length 2") + attr = {"pos": f"{node.position[0]},{node.position[1]}!"} + self.di_graph.nodes[node].update(attr) + dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() + dot_split = dot.split('\n') + dot_split.insert(1, f"graph [bgcolor={bgcolour}]") + dot_split.insert(1, f"node [style={node_style}]") + dot = "\n".join(dot_split) + if plot_title: + if plot_title is True: + plot_title = self.name + elif not isinstance(plot_title, str): + raise ValueError("Plot title must be a string, or True") + dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{plot_title}\";" + "}" + if not filename: + filename = self.name + viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') + viz_graph.view() + + @property + def density(self): + """Returns the density of the graph, ie. the proportion of possible edges between nodes + that exist in the graph""" + num_nodes = len(self.all_nodes) + num_edges = len(self.edges) + architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) + return architecture_density + + @property + def is_hierarchical(self): + """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" + if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: + return False + else: + return True + + @property + def is_connected(self): + return nx.is_connected(self.to_undirected) + + @property + def to_undirected(self): + return self.di_graph.to_undirected() + + def __len__(self): + return len(self.di_graph) + + @property + def fully_propagated(self): + """Checks if all data for each node have been transferred + to its descendants. With zero latency, this should be the case after running propagate""" + for edge in self.edges.edges: + if len(edge.unsent_data) != 0: + return False + + return True + + +class InformationArchitecture(Architecture): + """The architecture for how information is shared through the network. Node A is " + "connected to Node B if and only if the information A creates by processing and/or " + "sensing is received and opened by B without modification by another node. """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for node in self.all_nodes: + if isinstance(node, RepeaterNode): + raise TypeError("Information architecture should not contain any repeater nodes") + + def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, + **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: + """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ + all_detections = dict() + + # Get rid of ground truths that have not yet happened + # (ie GroundTruthState's with timestamp after self.current_time) + new_ground_truths = set() + for ground_truth_path in ground_truths: + # need an if len(states) == 0 continue condition here? + new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) + + for sensor_node in self.sensor_nodes: + all_detections[sensor_node] = set() + for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): + all_detections[sensor_node].add(detection) + + # Borrowed below from SensorSuite. I don't think it's necessary, but might be something + # we need. If so, will need to define self.attributes_inform + + # attributes_dict = \ + # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) + # for attribute_name in self.attributes_inform} + # + # for detection in all_detections[sensor_node]: + # detection.metadata.update(attributes_dict) + + for data in all_detections[sensor_node]: + # The sensor acquires its own data instantly + sensor_node.update(data.timestamp, data.timestamp, DataPiece(sensor_node, sensor_node, data, + data.timestamp), "created") + + return all_detections + + def propagate(self, time_increment: float, failed_edges: Collection = None): + """Performs the propagation of the measurements through the network""" + for edge in self.edges.edges: + if failed_edges and edge in failed_edges: + edge._failed(self.current_time, time_increment) + continue # No data passed along these edges + edge.update_messages(self.current_time) + # fuse goes here? + for data_piece, time_pertaining in edge.unsent_data: + edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) + + # for node in self.processing_nodes: + # node.process() # This should happen when a new message is received + count = 0 + if not self.fully_propagated: + count += 1 + self.propagate(time_increment, failed_edges) + return + + for fuse_node in self.fusion_nodes: + x = threading.Thread(target=fuse_node.fuse) + x.start() + + self.current_time += timedelta(seconds=time_increment) + + +class NetworkArchitecture(Architecture): + """The architecture for how data is propagated through the network. Node A is connected " + "to Node B if and only if A sends its data through B. """ + def propagate(self, time_increment: float): + # Still have to deal with latency/bandwidth + self.current_time += timedelta(seconds=time_increment) + for node in self.all_nodes: + for descendant in self.descendants(node): + for data in node.data_held: + descendant.update(self.current_time, data) + + +class CombinedArchitecture(Base): + """Contains an information and a network architecture that pertain to the same scenario. """ + information_architecture: InformationArchitecture = Property( + doc="The information architecture for how information is shared. ") + network_architecture: NetworkArchitecture = Property( + doc="The architecture for how data is propagated through the network. Node A is connected " + "to Node B if and only if A sends its data through B. ") + + def propagate(self, time_increment: float): + # First we simulate the network + self.network_architecture.propagate(time_increment) + # Now we want to only pass information along in the information architecture if it + # Was in the information architecture by at least one path. + # Some magic here + failed_edges = [] # return this from n_arch.propagate? + self.information_architecture.propagate(time_increment, failed_edges) + + +def _default_label(node, last_letters): + """Utility function to generate default labels for nodes, where none are given + Takes a node, and a dictionary with the letters last used for each class, + ie `last_letters['Node']` might return 'AA', meaning the last Node was labelled 'Node AA'""" + node_type = type(node).__name__ + type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' + new_letters = _default_letters(type_letters) + last_letters[node_type] = new_letters + return node_type + ' ' + new_letters, last_letters + + +def _default_letters(type_letters) -> str: + if type_letters == '': + return 'A' + count = 0 + letters_list = [*type_letters] + # Move through string from right to left and shift any Z's up to A's + while letters_list[-1 - count] == 'Z': + letters_list[-1 - count] = 'A' + count += 1 + if count == len(letters_list): + return 'A' * (count + 1) + # Shift current letter up by one + current_letter = letters_list[-1 - count] + letters_list[-1 - count] = auc[auc.index(current_letter) + 1] + new_letters = ''.join(letters_list) + return new_letters diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py new file mode 100644 index 000000000..5704e454d --- /dev/null +++ b/stonesoup/architecture/edge.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from ..base import Base, Property +from ..types.track import Track +from ..types.detection import Detection +from ..types.hypothesis import Hypothesis + +from typing import Union, Tuple, List, TYPE_CHECKING +from datetime import datetime, timedelta +from queue import Queue + +if TYPE_CHECKING: + from .node import Node + + +class FusionQueue(Queue): + """A queue from which fusion nodes draw data they have yet to fuse""" + def __init__(self): + super().__init__(maxsize=999999) + + def get_message(self): + value = self.get() + return value + + def set_message(self, value): + self.put(value) + + +class DataPiece(Base): + """A piece of data for use in an architecture. Sent via Messages, and stored in a Node's data_held""" + node: "Node" = Property( + doc="The Node this data piece belongs to") + originator: "Node" = Property( + doc="The node which first created this data, ie by sensing or fusing information together. " + "If the data is simply passed along the chain, the originator remains unchanged. ") + data: Union[Detection, Track, Hypothesis] = Property( + doc="A Detection, Track, or Hypothesis") + time_arrived: datetime = Property( + doc="The time at which this piece of data was received by the Node, either by Message or by sensing.") + track: Track = Property( + doc="The Track in the event of data being a Hypothesis", + default=None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sent_to = set() # all Nodes the data_piece has been sent to, to avoid duplicates + + +class Edge(Base): + """Comprised of two connected Nodes""" + nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (ancestor, descendant") + edge_latency: float = Property(doc="The latency stemming from the edge itself, " + "and not either of the nodes", + default=0) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.messages_held = {"pending": {}, # For pending, messages indexed by time sent. + "received": {}} # For received, by time received + self.time_ranges_failed = [] # List of time ranges during which this edge was failed + + def send_message(self, data_piece, time_pertaining, time_sent): + if not isinstance(data_piece, DataPiece): + raise TypeError("Message info must be one of the following types: " + "Detection, Hypothesis or Track") + # Add message to 'pending' dict of edge + message = Message(self, time_pertaining, time_sent, data_piece) + _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) + # ensure message not re-sent + data_piece.sent_to.add(self.nodes[1]) + + def update_messages(self, current_time): + # Check info type is what we expect + to_remove = set() # Needed as we can't change size of a set during iteration + for time in self.messages_held['pending']: + for message in self.messages_held['pending'][time]: + message.update(current_time) + if message.status == 'received': + # Then the latency has passed and message has been received + # Move message from pending to received messages in edge + to_remove.add((time, message)) + _, self.messages_held = _dict_set(self.messages_held, message, + 'received', message.arrival_time) + # Update + message.recipient_node.update(message.time_pertaining, message.arrival_time, + message.data_piece, "unfused") + + for time, message in to_remove: + self.messages_held['pending'][time].remove(message) + + def _failed(self, current_time, duration): + """Keeps track of when this edge was failed using the time_ranges_failed property. """ + end_time = current_time + timedelta(duration) + self.time_ranges_failed.append(TimeRange(current_time, end_time)) + + @property + def ancestor(self): + return self.nodes[0] + + @property + def descendant(self): + return self.nodes[1] + + @property + def ovr_latency(self): + """Overall latency including the two Nodes and the edge latency.""" + return self.ancestor.latency + self.edge_latency + self.descendant.latency + + @property + def unsent_data(self): + """Data held by the ancestor that has not been sent to the descendant.""" + unsent = [] + for status in ["fused", "created"]: + for time_pertaining in self.ancestor.data_held[status]: + for data_piece in self.ancestor.data_held[status][time_pertaining]: + if self.descendant not in data_piece.sent_to: + unsent.append((data_piece, time_pertaining)) + return unsent + + +class Edges(Base): + """Container class for Edge""" + edges: List[Edge] = Property(doc="List of Edge objects", default=None) + + def add(self, edge): + self.edges.append(edge) + + def get(self, node_pair): + if not isinstance(node_pair, Tuple) and all(isinstance(node, Node) for node in node_pair): + raise TypeError("Must supply a tuple of nodes") + if not len(node_pair) == 2: + raise ValueError("Incorrect tuple length. Must be of length 2") + for edge in self.edges: + if edge.nodes == node_pair: + # Assume this is the only match? + return edge + return None + + @property + def edge_list(self): + """Returns a list of tuples in the form (ancestor, descendant)""" + edge_list = [] + for edge in self.edges: + edge_list.append(edge.nodes) + return edge_list + + def __len__(self): + return len(self.edges) + + +class Message(Base): + """A message, containing a piece of information, that gets propagated between two Nodes. + Messages are opened by nodes that are a descendant of the node that sent the message""" + edge: Edge = Property( + doc="The directed edge containing the sender and receiver of the message") + time_pertaining: datetime = Property( + doc="The latest time for which the data pertains. For a Detection, this would be the time " + "of the Detection, or for a Track this is the time of the last State in the Track. " + "Different from time_sent when data is passed on that was not generated by this " + "Node's ancestor") + time_sent: datetime = Property( + doc="Time at which the message was sent") + data_piece: DataPiece = Property( + doc="Info that the sent message contains") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.status = "sending" + + @property + def generator_node(self): + return self.edge.ancestor + + @property + def recipient_node(self): + return self.edge.descendant + + @property + def arrival_time(self): + # TODO: incorporate failed time ranges here. Not essential for a first PR. Could do with merging of PR #664 + return self.time_sent + timedelta(seconds=self.edge.ovr_latency) + + def update(self, current_time): + progress = (current_time - self.time_sent).total_seconds() + if progress < self.edge.ancestor.latency: + self.status = "sending" + elif progress < self.edge.ancestor.latency + self.edge.edge_latency: + self.status = "transferring" + elif progress < self.edge.ovr_latency: + self.status = "receiving" + else: + self.status = "received" diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py new file mode 100644 index 000000000..16c8e4681 --- /dev/null +++ b/stonesoup/architecture/node.py @@ -0,0 +1,140 @@ +from ..base import Property, Base +from ..sensor.sensor import Sensor +from ..types.detection import Detection +from ..types.hypothesis import Hypothesis +from ..types.track import Track +from ..tracker.base import Tracker +from .edge import DataPiece, FusionQueue + +from datetime import datetime +from typing import Tuple + + +class Node(Base): + """Base node class. Should be abstract""" + latency: float = Property( + doc="Contribution to edge latency stemming from this node", + default=0) + label: str = Property( + doc="Label to be displayed on graph", + default=None) + position: Tuple[float] = Property( + default=None, + doc="Cartesian coordinates for node") + colour: str = Property( + default=None, + doc='Colour to be displayed on graph') + shape: str = Property( + default=None, + doc='Shape used to display nodes') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.data_held = {"fused": {}, "created": {}, "unfused": {}} + + def update(self, time_pertaining, time_arrived, data_piece, category, track=None): + if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): + raise TypeError("Times must be datetime objects") + if not track: + if not isinstance(data_piece.data, Detection) and not isinstance(data_piece.data, Track): + raise TypeError(f"Data provided without accompanying Track must be a Detection or a Track, not a " + f"{type(data_piece.data).__name__}") + new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, time_arrived) + else: + if not isinstance(data_piece.data, Hypothesis): + raise TypeError("Data provided with Track must be a Hypothesis") + new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, time_arrived, track) + + added, self.data_held[category] = _dict_set(self.data_held[category], new_data_piece, time_pertaining) + if isinstance(self, FusionNode): + self.fusion_queue.set_message(new_data_piece) + + return added + + +class SensorNode(Node): + """A node corresponding to a Sensor. Fresh data is created here""" + sensor: Sensor = Property(doc="Sensor corresponding to this node") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = '#1f77b4' + if not self.shape: + self.shape = 'square' + + +class FusionNode(Node): + """A node that does not measure new data, but does process data it receives""" + # feeder probably as well + tracker: Tracker = Property( + doc="Tracker used by this Node to fuse together Tracks and Detections") + fusion_queue: FusionQueue = Property(doc="The queue from which this node draws data to be fused") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = '#006400' + if not self.shape: + self.shape = 'hexagon' + self.tracks = set() # Set of tracks this Node has recorded + + def fuse(self): + print("A node be fusin") + # we have a queue. + data = self.fusion_queue.get_message() + if data: + # track it + print("there's data") + else: + print("no data") + return + + +class SensorFusionNode(SensorNode, FusionNode): + """A node that is both a sensor and also processes data""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.colour in ['#006400', '#1f77b4']: + self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating + if self.shape in ['square', 'hexagon']: + self.shape = 'octagon' + + +class RepeaterNode(Node): + """A node which simply passes data along to others, without manipulating the data itself. """ + # Latency property could go here + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = '#ff7f0e' + if not self.shape: + self.shape = 'circle' + + +def _dict_set(my_dict, value, key1, key2=None): + """Utility function to add value to my_dict at the specified key(s) + Returns True iff the set increased in size, ie the value was new to its position""" + if not my_dict: + if key2: + my_dict = {key1: {key2: {value}}} + else: + my_dict = {key1: {value}} + elif key2: + if key1 in my_dict: + if key2 in my_dict[key1]: + old_len = len(my_dict[key1][key2]) + my_dict[key1][key2].add(value) + return len(my_dict[key1][key2]) == old_len + 1, my_dict + else: + my_dict[key1][key2] = {value} + else: + my_dict[key1] = {key2: {value}} + else: + if key1 in my_dict: + old_len = len(my_dict[key1]) + my_dict[key1].add(value) + return len(my_dict[key1]) == old_len + 1, my_dict + else: + my_dict[key1] = {value} + return True, my_dict From 846ba16677f9290e888b2b79ff5459e1229fca30 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 15 May 2023 13:41:41 +0100 Subject: [PATCH 037/170] Changes to improve plot aesthetics --- stonesoup/architecture/architecture.py | 18 +++++++++------- stonesoup/architecture/node.py | 30 ++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py index cfa9491a8..a9ada6224 100644 --- a/stonesoup/architecture/architecture.py +++ b/stonesoup/architecture/architecture.py @@ -30,12 +30,14 @@ class Architecture(Base): doc="If True, the undirected version of the graph must be connected, ie. all nodes should " "be connected via some path. Set this to False to allow an unconnected architecture. " "Default is True") - font_size: int = Property( - default=8, - doc='Font size for node labels') - node_dim: tuple = Property( - default=(0.5, 0.5), - doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') + # Below is no longer required with changes to plot - didn't delete in case we want to revert + # to previous method + # font_size: int = Property( + # default=8, + # doc='Font size for node labels') + # node_dim: tuple = Property( + # default=(0.5, 0.5), + # doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -60,8 +62,8 @@ def __init__(self, *args, **kwargs): label, last_letters = _default_label(node, last_letters) node.label = label attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", - "fontsize": f"{self.font_size}", "width": f"{self.node_dim[0]}", - "height": f"{self.node_dim[1]}", "fixedsize": True} + "fontsize": f"{node.font_size}", "width": f"{node.node_dim[0]}", + "height": f"{node.node_dim[1]}", "fixedsize": True} self.di_graph.nodes[node].update(attr) def descendants(self, node: Node): diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 16c8e4681..acc84365a 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -27,6 +27,12 @@ class Node(Base): shape: str = Property( default=None, doc='Shape used to display nodes') + font_size: int = Property( + default=None, + doc='Font size for node labels') + node_dim: tuple = Property( + default=None, + doc='Width and height of nodes for graph icons, default is (0.5, 0.5)') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -61,7 +67,11 @@ def __init__(self, *args, **kwargs): if not self.colour: self.colour = '#1f77b4' if not self.shape: - self.shape = 'square' + self.shape = 'oval' + if not self.font_size: + self.font_size = 5 + if not self.node_dim: + self.node_dim = (0.5, 0.3) class FusionNode(Node): @@ -77,6 +87,10 @@ def __init__(self, *args, **kwargs): self.colour = '#006400' if not self.shape: self.shape = 'hexagon' + if not self.font_size: + self.font_size = 5 + if not self.node_dim: + self.node_dim = (0.6, 0.3) self.tracks = set() # Set of tracks this Node has recorded def fuse(self): @@ -96,9 +110,13 @@ class SensorFusionNode(SensorNode, FusionNode): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.colour in ['#006400', '#1f77b4']: - self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating - if self.shape in ['square', 'hexagon']: - self.shape = 'octagon' + self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating + if self.shape in ['oval', 'hexagon']: + self.shape = 'rectangle' + if not self.font_size: + self.font_size = 5 + if not self.node_dim: + self.node_dim = (0.1, 0.3) class RepeaterNode(Node): @@ -110,6 +128,10 @@ def __init__(self, *args, **kwargs): self.colour = '#ff7f0e' if not self.shape: self.shape = 'circle' + if not self.font_size: + self.font_size = 5 + if not self.node_dim: + self.node_dim = (0.5, 0.3) def _dict_set(my_dict, value, key1, key2=None): From ccb6689f522c4f4f8ca6f0f82451789fc4a70d36 Mon Sep 17 00:00:00 2001 From: spike Date: Fri, 19 May 2023 15:08:03 +0100 Subject: [PATCH 038/170] Bug fix to base.py to allow circular imports --- stonesoup/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stonesoup/base.py b/stonesoup/base.py index f68afa4fd..ab6185cf9 100644 --- a/stonesoup/base.py +++ b/stonesoup/base.py @@ -61,6 +61,7 @@ def __init__(self, foo, bar=bar.default, *args, **kwargs): from copy import copy from functools import cached_property from types import MappingProxyType +from typing import TYPE_CHECKING class Property: From ebfe7b16cdf55ad287f714853090d58dadb11025 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 25 May 2023 15:41:07 +0100 Subject: [PATCH 039/170] Progress on fusion algorithm --- stonesoup/architecture/architecture.py | 4 +- stonesoup/architecture/node.py | 11 ++++- stonesoup/tracker/fusion.py | 62 +++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py index a9ada6224..4e7e5d73b 100644 --- a/stonesoup/architecture/architecture.py +++ b/stonesoup/architecture/architecture.py @@ -19,8 +19,8 @@ class Architecture(Base): "Edge with edge_pair=(A, B) in this object.") current_time: datetime = Property( doc="The time which the instance is at for the purpose of simulation. " - "This is increased by the propagate method. This should be set to the earliest timestep " - "from the ground truth") + "This is increased by the propagate method. This should be set to the earliest timestep" + " from the ground truth") name: str = Property( default=None, doc="A name for the architecture, to be used to name files and/or title plots. Default is " diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index acc84365a..f4afa2c0a 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -5,7 +5,7 @@ from ..types.track import Track from ..tracker.base import Tracker from .edge import DataPiece, FusionQueue - +from ..tracker.fusion import SimpleFusionTracker from datetime import datetime from typing import Tuple @@ -79,7 +79,10 @@ class FusionNode(Node): # feeder probably as well tracker: Tracker = Property( doc="Tracker used by this Node to fuse together Tracks and Detections") - fusion_queue: FusionQueue = Property(doc="The queue from which this node draws data to be fused") + fusion_queue: FusionQueue = Property( + doc="The queue from which this node draws data to be fused") + track_fusion_tracker: Tracker = Property( + doc="Tracker for associating tracks at the node") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -97,6 +100,10 @@ def fuse(self): print("A node be fusin") # we have a queue. data = self.fusion_queue.get_message() + + # Sort detections and tracks and group by time + + if data: # track it print("there's data") diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index c80c2ff82..63bef2bc3 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -1,4 +1,62 @@ +import datetime + from .base import Tracker +from ..base import Property +from stonesoup.buffered_generator import BufferedGenerator +from stonesoup.dataassociator.tracktotrack import TrackToTrackCounting +from stonesoup.reader.base import DetectionReader +from stonesoup.types.detection import Detection +from stonesoup.types.track import Track + + +class DummyDetector(DetectionReader): + def __init__(self, *args, **kwargs): + self.current = kwargs['current'] + + @BufferedGenerator.generator_method + def detections_gen(self): + yield self.current + + +class SimpleFusionTracker(Tracker): + """Presumes data from this node are detections, and from every other node are tracks""" + tracker: Tracker = Property(doc="Tracker given to the fusion node") + track_fusion_tracker: Tracker = Property( + doc="Tracker for associating tracks at the node") + sliding_window = Property(doc="The number of time steps before the result is fixed") + data = Property(doc="data received from queue and sensor") + current_time: datetime.datetime = Property(doc='Current time in simulation') + + def __next__(self): + # Get data in time window + data_in_window = set() + data_not_in_window = set() + for data_piece in self.data: + if (self.current_time - datetime.timedelta(seconds=self.sliding_window) < + data_piece.time_arrived <= self.current_time): + data_in_window.add(data_piece) + else: + # Not sure what we want to do with this + data_not_in_window.add(data_piece) + + cdets = set() + ctracks = set() + for data_piece in data_in_window: + if isinstance(data_piece, Detection): + cdets.add(data_piece) + elif isinstance(data_piece, Track): + ctracks.add(data_piece) + + # run our tracker on our detections + tracks = set() + for time, ctracks in self.tracker: + tracks.update(ctracks) + + # Take the tracks and combine them, accounting for the fact that they might not all be as + # up-to-date as one another + for tracks in [ctracks] + dummy_detector = DummyDetector(current=[time, ]) + + -class MultiTargetFusionTracker(Tracker): - """Takes Detections and Tracks, and fuses them in the style of MultiTargetTracker""" \ No newline at end of file + # Don't edit anything that comes before the current time - the sliding window. That's fixed forever. \ No newline at end of file From d91d2cb923d1ecd1e7b28ca3b7507161004af11d Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 30 May 2023 10:04:52 +0100 Subject: [PATCH 040/170] more tracker progress --- stonesoup/tracker/fusion.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index 63bef2bc3..6efb93736 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -7,8 +7,8 @@ from stonesoup.reader.base import DetectionReader from stonesoup.types.detection import Detection from stonesoup.types.track import Track - - +from stonesoup.tracker.pointprocess import PointProcessMultiTargetTracker +from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder class DummyDetector(DetectionReader): def __init__(self, *args, **kwargs): self.current = kwargs['current'] @@ -20,12 +20,16 @@ def detections_gen(self): class SimpleFusionTracker(Tracker): """Presumes data from this node are detections, and from every other node are tracks""" - tracker: Tracker = Property(doc="Tracker given to the fusion node") + tracker: Tracker = Property( + doc="Tracker given to the fusion node") track_fusion_tracker: Tracker = Property( doc="Tracker for associating tracks at the node") - sliding_window = Property(doc="The number of time steps before the result is fixed") - data = Property(doc="data received from queue and sensor") - current_time: datetime.datetime = Property(doc='Current time in simulation') + sliding_window = Property( + doc="The number of time steps before the result is fixed") + data = Property( + doc="data received from queue and sensor") + current_time: datetime.datetime = Property( + doc='Current time in simulation') def __next__(self): # Get data in time window @@ -33,7 +37,7 @@ def __next__(self): data_not_in_window = set() for data_piece in self.data: if (self.current_time - datetime.timedelta(seconds=self.sliding_window) < - data_piece.time_arrived <= self.current_time): + data_piece.time_arrived <= self.current_time): data_in_window.add(data_piece) else: # Not sure what we want to do with this @@ -54,9 +58,12 @@ def __next__(self): # Take the tracks and combine them, accounting for the fact that they might not all be as # up-to-date as one another - for tracks in [ctracks] - dummy_detector = DummyDetector(current=[time, ]) - + for tracks in [ctracks]: + dummy_detector = DummyDetector(current=[time, tracks]) + self.track_fusion_tracker.detector = Tracks2GaussianDetectionFeeder(dummy_detector) + self.track_fusion_tracker.__iter__() + _, tracks = next(self.track_fusion_tracker) + self.track_fusion_tracks.update(tracks) # Don't edit anything that comes before the current time - the sliding window. That's fixed forever. \ No newline at end of file From 1259fcc03a2e935fa9d6d112512ee0a71312afe2 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 20 Jun 2023 11:03:31 +0100 Subject: [PATCH 041/170] Notes and minor tweaks on fusion tracker --- stonesoup/tracker/fusion.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index 6efb93736..adc3ae8fd 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -18,23 +18,28 @@ def detections_gen(self): yield self.current -class SimpleFusionTracker(Tracker): - """Presumes data from this node are detections, and from every other node are tracks""" - tracker: Tracker = Property( +class SimpleFusionTracker(Tracker): # implement tracks method + """Presumes data from this node are detections, and from every other node are tracks + Acts as a wrapper around a base tracker. Track is fixed after the sliding window. + It exists within it, but the States may change. """ + base_tracker: Tracker = Property( doc="Tracker given to the fusion node") - track_fusion_tracker: Tracker = Property( - doc="Tracker for associating tracks at the node") - sliding_window = Property( + # Question (for Alasdair??): Do we want an over-arching fusion tracker, + # or give each Node its own? Probably latter + sliding_window = Property( # This entails assumptions: fixed time intervals for one. + # Do we express in seconds, or steps doc="The number of time steps before the result is fixed") - data = Property( - doc="data received from queue and sensor") - current_time: datetime.datetime = Property( - doc='Current time in simulation') + data = Property( # better name for this + doc="Data received from queue and sensor") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tracks = set() def __next__(self): # Get data in time window - data_in_window = set() - data_not_in_window = set() + data_in_window = set() #? Convert to list? If ordered we can remove [0] and add to end. + data_not_in_window = set() # This should just be fixed_track or something like that (ie a Track object) for data_piece in self.data: if (self.current_time - datetime.timedelta(seconds=self.sliding_window) < data_piece.time_arrived <= self.current_time): @@ -63,7 +68,9 @@ def __next__(self): self.track_fusion_tracker.detector = Tracks2GaussianDetectionFeeder(dummy_detector) self.track_fusion_tracker.__iter__() _, tracks = next(self.track_fusion_tracker) - self.track_fusion_tracks.update(tracks) + self.track_fusion_tracker.update(tracks) + + return time, self.tracks - # Don't edit anything that comes before the current time - the sliding window. That's fixed forever. \ No newline at end of file + # Don't edit anything that comes before the current time - the sliding window. That's fixed forever. From 98f576c3ca275af1cc9dddeca4a76cce536dbce6 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Wed, 28 Jun 2023 14:00:29 +0100 Subject: [PATCH 042/170] Notes and minor tweaks on fusion tracker 2 --- stonesoup/tracker/fusion.py | 17 +++++++---------- stonesoup/tracker/tests/test_fusion.py | 9 +++++++++ 2 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 stonesoup/tracker/tests/test_fusion.py diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index adc3ae8fd..077bbf783 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -9,6 +9,8 @@ from stonesoup.types.track import Track from stonesoup.tracker.pointprocess import PointProcessMultiTargetTracker from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder + + class DummyDetector(DetectionReader): def __init__(self, *args, **kwargs): self.current = kwargs['current'] @@ -18,19 +20,14 @@ def detections_gen(self): yield self.current -class SimpleFusionTracker(Tracker): # implement tracks method +class SimpleFusionTracker(Tracker): # implement tracks method """Presumes data from this node are detections, and from every other node are tracks Acts as a wrapper around a base tracker. Track is fixed after the sliding window. It exists within it, but the States may change. """ - base_tracker: Tracker = Property( - doc="Tracker given to the fusion node") - # Question (for Alasdair??): Do we want an over-arching fusion tracker, - # or give each Node its own? Probably latter - sliding_window = Property( # This entails assumptions: fixed time intervals for one. - # Do we express in seconds, or steps - doc="The number of time steps before the result is fixed") - data = Property( # better name for this - doc="Data received from queue and sensor") + base_tracker: Tracker = Property(doc="Tracker given to the fusion node") + sliding_window = Property(default=30, + doc="The number of time steps before the result is fixed") + queue = Property(default=None, doc="Queue which feeds in data") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/stonesoup/tracker/tests/test_fusion.py b/stonesoup/tracker/tests/test_fusion.py new file mode 100644 index 000000000..745c7d64b --- /dev/null +++ b/stonesoup/tracker/tests/test_fusion.py @@ -0,0 +1,9 @@ +from ..fusion import SimpleFusionTracker +from ..simple import MultiTargetTracker + + +def test_fusion_tracker(initiator, deleter, detector, data_associator, updater): + base_tracker = MultiTargetTracker( + initiator, deleter, detector, data_associator, updater) + tracker = SimpleFusionTracker(base_tracker, 30) + assert True From 37afe4522f265d07d6af39172d839009ea6bc2ba Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 6 Jul 2023 13:07:18 +0100 Subject: [PATCH 043/170] Fusion Tracker updates --- stonesoup/architecture/node.py | 5 ++++- stonesoup/tracker/fusion.py | 41 ++++++++++++++++++++-------------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index f4afa2c0a..20bef2573 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -83,6 +83,8 @@ class FusionNode(Node): doc="The queue from which this node draws data to be fused") track_fusion_tracker: Tracker = Property( doc="Tracker for associating tracks at the node") + tracks: set = Property(default=None, + doc="Set of tracks tracked by the fusion node") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -103,10 +105,11 @@ def fuse(self): # Sort detections and tracks and group by time - if data: # track it print("there's data") + for time, track in self.tracker: + self.tracks.update(track) else: print("no data") return diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index 077bbf783..81bf82377 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -25,26 +25,33 @@ class SimpleFusionTracker(Tracker): # implement tracks method Acts as a wrapper around a base tracker. Track is fixed after the sliding window. It exists within it, but the States may change. """ base_tracker: Tracker = Property(doc="Tracker given to the fusion node") - sliding_window = Property(default=30, + sliding_window: int = Property(default=30, doc="The number of time steps before the result is fixed") - queue = Property(default=None, doc="Queue which feeds in data") + queue: = Property(default=None, doc="Queue which feeds in data") + current_time: datetime = Property() + detector: DetectionReader = Property(doc= "Detection reader to read detections from the queue") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tracks = set() + @property + def tracks(self): + return self._tracks + + def __iter__(self): + self.detector_iter = iter(self.detector) + return super().__iter__() + def __next__(self): # Get data in time window - data_in_window = set() #? Convert to list? If ordered we can remove [0] and add to end. - data_not_in_window = set() # This should just be fixed_track or something like that (ie a Track object) - for data_piece in self.data: + + # I think this block of code is equivalent to a detection reader? + data_in_window = list() + for data_piece in self.queue: if (self.current_time - datetime.timedelta(seconds=self.sliding_window) < data_piece.time_arrived <= self.current_time): - data_in_window.add(data_piece) - else: - # Not sure what we want to do with this - data_not_in_window.add(data_piece) - + data_in_window.append(data_piece) cdets = set() ctracks = set() for data_piece in data_in_window: @@ -55,17 +62,17 @@ def __next__(self): # run our tracker on our detections tracks = set() - for time, ctracks in self.tracker: + for time, ctracks in self.base_tracker: tracks.update(ctracks) # Take the tracks and combine them, accounting for the fact that they might not all be as # up-to-date as one another - for tracks in [ctracks]: - dummy_detector = DummyDetector(current=[time, tracks]) - self.track_fusion_tracker.detector = Tracks2GaussianDetectionFeeder(dummy_detector) - self.track_fusion_tracker.__iter__() - _, tracks = next(self.track_fusion_tracker) - self.track_fusion_tracker.update(tracks) + # for tracks in [ctracks]: + # dummy_detector = DummyDetector(current=[time, tracks]) + # self.track_fusion_tracker.detector = Tracks2GaussianDetectionFeeder(dummy_detector) + # self.track_fusion_tracker.__iter__() + # _, tracks = next(self.track_fusion_tracker) + # self.track_fusion_tracker.update(tracks) return time, self.tracks From 6768800a06c6154b7a4fb6c323ecad7aede4596c Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 6 Jul 2023 17:10:22 +0100 Subject: [PATCH 044/170] More work on tracker, still not working --- stonesoup/architecture/architecture.py | 3 +++ stonesoup/architecture/node.py | 2 ++ stonesoup/tracker/fusion.py | 37 +++++++++++++++++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py index 4e7e5d73b..7a0b0c2ea 100644 --- a/stonesoup/architecture/architecture.py +++ b/stonesoup/architecture/architecture.py @@ -13,6 +13,7 @@ from datetime import datetime, timedelta import threading + class Architecture(Base): edges: Edges = Property( doc="An Edges object containing all edges. For A to be connected to B we would have an " @@ -259,6 +260,8 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): x.start() self.current_time += timedelta(seconds=time_increment) + for node in self.fusion_nodes: + node.track_fusion_tracker.current_time = self.current_time class NetworkArchitecture(Architecture): diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 20bef2573..7428c3b78 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -80,8 +80,10 @@ class FusionNode(Node): tracker: Tracker = Property( doc="Tracker used by this Node to fuse together Tracks and Detections") fusion_queue: FusionQueue = Property( + default=FusionQueue(), doc="The queue from which this node draws data to be fused") track_fusion_tracker: Tracker = Property( + default=SimpleFusionTracker(), doc="Tracker for associating tracks at the node") tracks: set = Property(default=None, doc="Set of tracks tracked by the fusion node") diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index 81bf82377..2ad6593b2 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -1,4 +1,5 @@ import datetime +import numpy as np from .base import Tracker from ..base import Property @@ -8,11 +9,24 @@ from stonesoup.types.detection import Detection from stonesoup.types.track import Track from stonesoup.tracker.pointprocess import PointProcessMultiTargetTracker +from stonesoup.tracker.simple import MultiTargetTracker from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder +from stonesoup.architecture.edge import FusionQueue + +from stonesoup.models.transition.categorical import MarkovianTransitionModel +from stonesoup.types.state import CategoricalState +from stonesoup.predictor.categorical import HMMPredictor +from stonesoup.updater.categorical import HMMUpdater +from stonesoup.hypothesiser.categorical import HMMHypothesiser +from stonesoup.dataassociator.neighbour import GNNWith2DAssignment +from stonesoup.initiator.categorical import SimpleCategoricalMeasurementInitiator +from stonesoup.deleter.time import UpdateTimeStepsDeleter +from stonesoup.tracker.tests.conftest import detector class DummyDetector(DetectionReader): def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.current = kwargs['current'] @BufferedGenerator.generator_method @@ -24,11 +38,25 @@ class SimpleFusionTracker(Tracker): # implement tracks method """Presumes data from this node are detections, and from every other node are tracks Acts as a wrapper around a base tracker. Track is fixed after the sliding window. It exists within it, but the States may change. """ - base_tracker: Tracker = Property(doc="Tracker given to the fusion node") + base_tracker: Tracker = Property( + default=MultiTargetTracker(initiator=SimpleCategoricalMeasurementInitiator( + prior_state=CategoricalState([1 / 2, 1 / 2], + categories=['Friendly', 'Enemy']), + updater=HMMUpdater()), + deleter=UpdateTimeStepsDeleter(2), + detector=detector, + data_associator=GNNWith2DAssignment( + HMMHypothesiser( + predictor=HMMPredictor(MarkovianTransitionModel( + transition_matrix=np.array([[0.6, 0.4], + [0.39, 0.61]]))), + updater=HMMUpdater())), + updater=HMMUpdater()), + doc="Tracker given to the fusion node") sliding_window: int = Property(default=30, doc="The number of time steps before the result is fixed") - queue: = Property(default=None, doc="Queue which feeds in data") - current_time: datetime = Property() + queue: FusionQueue = Property(default=None, doc="Queue which feeds in data") + current_time: datetime.datetime = Property(default=datetime.datetime(2000, 1, 1, 0, 0, 0, 1)) detector: DetectionReader = Property(doc= "Detection reader to read detections from the queue") def __init__(self, *args, **kwargs): @@ -44,6 +72,9 @@ def __iter__(self): return super().__iter__() def __next__(self): + if self.current_time == datetime.datetime(2000, 1, 1, 0, 0, 0, 1): + raise ValueError("current_time tracker property hasn't updated to match Architecture " + "current_time") # Get data in time window # I think this block of code is equivalent to a detection reader? From dba51f565015a2b9ba19791e3508940c391693f3 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 21 Aug 2023 12:52:37 +0100 Subject: [PATCH 045/170] Add automated hierarchical node positioning for fusion architectures plot function --- stonesoup/architecture/architecture.py | 183 ++++++++++++++++++++++--- stonesoup/architecture/edge.py | 30 ++-- stonesoup/architecture/node.py | 2 +- stonesoup/tracker/fusion.py | 22 +-- 4 files changed, 190 insertions(+), 47 deletions(-) diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py index 7a0b0c2ea..8d280b802 100644 --- a/stonesoup/architecture/architecture.py +++ b/stonesoup/architecture/architecture.py @@ -19,6 +19,7 @@ class Architecture(Base): doc="An Edges object containing all edges. For A to be connected to B we would have an " "Edge with edge_pair=(A, B) in this object.") current_time: datetime = Property( + default=datetime.now(), doc="The time which the instance is at for the purpose of simulation. " "This is increased by the propagate method. This should be set to the earliest timestep" " from the ground truth") @@ -31,6 +32,7 @@ class Architecture(Base): doc="If True, the undirected version of the graph must be connected, ie. all nodes should " "be connected via some path. Set this to False to allow an unconnected architecture. " "Default is True") + # Below is no longer required with changes to plot - didn't delete in case we want to revert # to previous method # font_size: int = Property( @@ -67,25 +69,86 @@ def __init__(self, *args, **kwargs): "height": f"{node.node_dim[1]}", "fixedsize": True} self.di_graph.nodes[node].update(attr) - def descendants(self, node: Node): + def parents(self, node: Node): """Returns a set of all nodes to which the input node has a direct edge to""" if node not in self.all_nodes: raise ValueError("Node not in this architecture") - descendants = set() + parents = set() for other in self.all_nodes: if (node, other) in self.edges.edge_list: - descendants.add(other) - return descendants + parents.add(other) + return parents - def ancestors(self, node: Node): + def children(self, node: Node): """Returns a set of all nodes to which the input node has a direct edge from""" if node not in self.all_nodes: raise ValueError("Node not in this architecture") - ancestors = set() + children = set() for other in self.all_nodes: if (other, node) in self.edges.edge_list: - ancestors.add(other) - return ancestors + children.add(other) + return children + + def sibling_group(self, node: Node): + """Returns a set of siblings of the given node. The given node is included in this set.""" + siblings = set() + for parent in self.parents(node): + for child in self.children(parent): + siblings.add(child) + return siblings + + @property + def shortest_path_dict(self): + g = nx.DiGraph() + for edge in self.edges.edge_list: + g.add_edge(edge[0], edge[1]) + path = nx.all_pairs_shortest_path_length(g) + dpath = {x[0]: x[1] for x in path} + return dpath + + def _parent_position(self, node: Node): + """Returns a tuple of (x_coord, y_coord) giving the location of a node's parent""" + parents = self.parents(node) + if len(parents) == 1: + parent = parents.pop() + else: + raise ValueError("Node has more than one parent") + return parent.position + + @property + def top_nodes(self): + top_nodes = list() + for node in self.all_nodes: + if len(self.parents(node)) == 0: + # This node must be the top level node + top_nodes.append(node) + + return top_nodes + + def number_of_leaves(self, node: Node): + node_leaves = set() + non_leaves = 0 + for leaf_node in self.leaf_nodes: + try: + shortest_path = self.shortest_path_dict[leaf_node][node] + if shortest_path != 0: + node_leaves.add(leaf_node) + except KeyError: + non_leaves += 1 + + if len(node_leaves) == 0: + return 1 + else: + return len(node_leaves) + + @property + def leaf_nodes(self): + leaf_nodes = set() + for node in self.all_nodes: + if len(self.children(node)) == 0: + # This must be a leaf node + leaf_nodes.add(node) + return leaf_nodes @abstractmethod def propagate(self, time_increment: float): @@ -136,6 +199,66 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, "position, given as a Tuple of length 2") attr = {"pos": f"{node.position[0]},{node.position[1]}!"} self.di_graph.nodes[node].update(attr) + elif self.is_hierarchical: + + print("Hierarchical plotting is used") + + # Find top node and assign location + top_nodes = self.top_nodes + if len(top_nodes) == 1: + top_node = top_nodes[0] + else: + raise ValueError("Graph with more than one top level node provided.") + + top_node.position = (0, 0) + attr = {"pos": f"{top_node.position[0]},{top_node.position[1]}!"} + self.di_graph.nodes[top_node].update(attr) + + plotted_nodes = set() + + # Set of nodes that have been plotted, but need to have parent nodes plotted + layer_nodes = set() + layer_nodes.add(top_node) + plotted_nodes.add(top_node) + + layer = -1 + while len(plotted_nodes) < len(self.all_nodes): + + print(len(plotted_nodes)) + print(len(self.all_nodes)) + + nodes_to_pop = set() + next_layer_nodes = set() + for layer_node in layer_nodes: + + # Find children of the parent node + children = self.children(layer_node) + n_children = len(children) + print(layer_node.label, "n_children = ", n_children) + + # Find number of leaf nodes that descend from the parent + n_parent_leaves = self.number_of_leaves(layer_node) + + # Get parent x_loc + parent_x_loc = layer_node.position[0] + + # Get location of left limit of the range that leaf nodes will be plotted in + l_x_loc = parent_x_loc - n_parent_leaves/2 + left_limit = l_x_loc + + for child in children: + x_loc = left_limit + self.number_of_leaves(child)/2 + left_limit += self.number_of_leaves(child) + child.position = (x_loc, layer) + attr = {"pos": f"{child.position[0]},{child.position[1]}!"} + self.di_graph.nodes[child].update(attr) + next_layer_nodes.add(child) + plotted_nodes.add(child) + nodes_to_pop.add(layer_node) + + layer_nodes = next_layer_nodes + layer -= 1 + dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() dot_split = dot.split('\n') dot_split.insert(1, f"graph [bgcolor={bgcolour}]") @@ -158,16 +281,42 @@ def density(self): that exist in the graph""" num_nodes = len(self.all_nodes) num_edges = len(self.edges) - architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) + architecture_density = num_edges / ((num_nodes * (num_nodes - 1)) / 2) return architecture_density @property def is_hierarchical(self): """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" - if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: - return False + # if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: + no_parents = 0 + one_parent = 0 + multiple_parents = 0 + for node in self.all_nodes: + if len(self.parents(node)) == 0: + no_parents += 1 + elif len(self.parents(node)) == 1: + one_parent += 1 + elif len(self.parents(node)) > 1: + multiple_parents += 1 + + if multiple_parents == 0 and no_parents == 1: + return True else: + return False + + @property + def is_centralised(self): + n_parents = 0 + one_parent = 0 + multiple_parents = 0 + for node in self.all_nodes: + if len(node.children) == 0: + n_parents += 1 + + if n_parents == 0: return True + else: + return False @property def is_connected(self): @@ -183,7 +332,7 @@ def __len__(self): @property def fully_propagated(self): """Checks if all data for each node have been transferred - to its descendants. With zero latency, this should be the case after running propagate""" + to its parents. With zero latency, this should be the case after running propagate""" for edge in self.edges.edges: if len(edge.unsent_data) != 0: return False @@ -231,8 +380,9 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd for data in all_detections[sensor_node]: # The sensor acquires its own data instantly - sensor_node.update(data.timestamp, data.timestamp, DataPiece(sensor_node, sensor_node, data, - data.timestamp), "created") + sensor_node.update(data.timestamp, data.timestamp, + DataPiece(sensor_node, sensor_node, data, + data.timestamp), "created") return all_detections @@ -267,13 +417,14 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): class NetworkArchitecture(Architecture): """The architecture for how data is propagated through the network. Node A is connected " "to Node B if and only if A sends its data through B. """ + def propagate(self, time_increment: float): # Still have to deal with latency/bandwidth self.current_time += timedelta(seconds=time_increment) for node in self.all_nodes: - for descendant in self.descendants(node): + for parent in self.parents(node): for data in node.data_held: - descendant.update(self.current_time, data) + parent.update(self.current_time, data) class CombinedArchitecture(Base): diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 5704e454d..98583c63f 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -48,7 +48,7 @@ def __init__(self, *args, **kwargs): class Edge(Base): """Comprised of two connected Nodes""" - nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (ancestor, descendant") + nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (child, parent") edge_latency: float = Property(doc="The latency stemming from the edge itself, " "and not either of the nodes", default=0) @@ -94,26 +94,26 @@ def _failed(self, current_time, duration): self.time_ranges_failed.append(TimeRange(current_time, end_time)) @property - def ancestor(self): + def child(self): return self.nodes[0] @property - def descendant(self): + def parent(self): return self.nodes[1] @property def ovr_latency(self): """Overall latency including the two Nodes and the edge latency.""" - return self.ancestor.latency + self.edge_latency + self.descendant.latency + return self.child.latency + self.edge_latency + self.parent.latency @property def unsent_data(self): - """Data held by the ancestor that has not been sent to the descendant.""" + """Data held by the child that has not been sent to the parent.""" unsent = [] for status in ["fused", "created"]: - for time_pertaining in self.ancestor.data_held[status]: - for data_piece in self.ancestor.data_held[status][time_pertaining]: - if self.descendant not in data_piece.sent_to: + for time_pertaining in self.child.data_held[status]: + for data_piece in self.child.data_held[status][time_pertaining]: + if self.parent not in data_piece.sent_to: unsent.append((data_piece, time_pertaining)) return unsent @@ -138,7 +138,7 @@ def get(self, node_pair): @property def edge_list(self): - """Returns a list of tuples in the form (ancestor, descendant)""" + """Returns a list of tuples in the form (child, parent)""" edge_list = [] for edge in self.edges: edge_list.append(edge.nodes) @@ -150,14 +150,14 @@ def __len__(self): class Message(Base): """A message, containing a piece of information, that gets propagated between two Nodes. - Messages are opened by nodes that are a descendant of the node that sent the message""" + Messages are opened by nodes that are a parent of the node that sent the message""" edge: Edge = Property( doc="The directed edge containing the sender and receiver of the message") time_pertaining: datetime = Property( doc="The latest time for which the data pertains. For a Detection, this would be the time " "of the Detection, or for a Track this is the time of the last State in the Track. " "Different from time_sent when data is passed on that was not generated by this " - "Node's ancestor") + "Node's child") time_sent: datetime = Property( doc="Time at which the message was sent") data_piece: DataPiece = Property( @@ -169,11 +169,11 @@ def __init__(self, *args, **kwargs): @property def generator_node(self): - return self.edge.ancestor + return self.edge.child @property def recipient_node(self): - return self.edge.descendant + return self.edge.parent @property def arrival_time(self): @@ -182,9 +182,9 @@ def arrival_time(self): def update(self, current_time): progress = (current_time - self.time_sent).total_seconds() - if progress < self.edge.ancestor.latency: + if progress < self.edge.child.latency: self.status = "sending" - elif progress < self.edge.ancestor.latency + self.edge.edge_latency: + elif progress < self.edge.child.latency + self.edge.edge_latency: self.status = "transferring" elif progress < self.edge.ovr_latency: self.status = "receiving" diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 7428c3b78..2a677854a 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -83,7 +83,7 @@ class FusionNode(Node): default=FusionQueue(), doc="The queue from which this node draws data to be fused") track_fusion_tracker: Tracker = Property( - default=SimpleFusionTracker(), + default=None, #SimpleFusionTracker(), doc="Tracker for associating tracks at the node") tracks: set = Property(default=None, doc="Set of tracks tracked by the fusion node") diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index 2ad6593b2..457749858 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -38,21 +38,13 @@ class SimpleFusionTracker(Tracker): # implement tracks method """Presumes data from this node are detections, and from every other node are tracks Acts as a wrapper around a base tracker. Track is fixed after the sliding window. It exists within it, but the States may change. """ - base_tracker: Tracker = Property( - default=MultiTargetTracker(initiator=SimpleCategoricalMeasurementInitiator( - prior_state=CategoricalState([1 / 2, 1 / 2], - categories=['Friendly', 'Enemy']), - updater=HMMUpdater()), - deleter=UpdateTimeStepsDeleter(2), - detector=detector, - data_associator=GNNWith2DAssignment( - HMMHypothesiser( - predictor=HMMPredictor(MarkovianTransitionModel( - transition_matrix=np.array([[0.6, 0.4], - [0.39, 0.61]]))), - updater=HMMUpdater())), - updater=HMMUpdater()), - doc="Tracker given to the fusion node") + base_tracker: Tracker = Property(default=None,#MultiTargetTracker( + # initiator=SimpleCategoricalMeasurementInitiator(prior_state=CategoricalState([1 / 2, 1 / 2], categories=['Friendly', 'Enemy']), updater=HMMUpdater()), + # deleter=UpdateTimeStepsDeleter(2), + # detector=, + # data_associator=GNNWith2DAssignment(HMMHypothesiser(predictor=HMMPredictor(MarkovianTransitionModel(transition_matrix=np.array([[0.6, 0.4], [0.39, 0.61]]))), updater=HMMUpdater())), + # updater=HMMUpdater()), + doc="Tracker given to the fusion node") sliding_window: int = Property(default=30, doc="The number of time steps before the result is fixed") queue: FusionQueue = Property(default=None, doc="Queue which feeds in data") From c66d70aec3f0e24de6f8a217d626af17e86b0a55 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Wed, 26 Jul 2023 12:04:49 +0100 Subject: [PATCH 046/170] sketch changes to tracker --- stonesoup/architecture/architecture.py | 189 +++---------------------- stonesoup/tracker/fusion.py | 98 +++++-------- 2 files changed, 55 insertions(+), 232 deletions(-) diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py index 8d280b802..81bf2ea8f 100644 --- a/stonesoup/architecture/architecture.py +++ b/stonesoup/architecture/architecture.py @@ -13,13 +13,11 @@ from datetime import datetime, timedelta import threading - class Architecture(Base): edges: Edges = Property( doc="An Edges object containing all edges. For A to be connected to B we would have an " "Edge with edge_pair=(A, B) in this object.") current_time: datetime = Property( - default=datetime.now(), doc="The time which the instance is at for the purpose of simulation. " "This is increased by the propagate method. This should be set to the earliest timestep" " from the ground truth") @@ -32,7 +30,6 @@ class Architecture(Base): doc="If True, the undirected version of the graph must be connected, ie. all nodes should " "be connected via some path. Set this to False to allow an unconnected architecture. " "Default is True") - # Below is no longer required with changes to plot - didn't delete in case we want to revert # to previous method # font_size: int = Property( @@ -69,86 +66,25 @@ def __init__(self, *args, **kwargs): "height": f"{node.node_dim[1]}", "fixedsize": True} self.di_graph.nodes[node].update(attr) - def parents(self, node: Node): + def descendants(self, node: Node): """Returns a set of all nodes to which the input node has a direct edge to""" if node not in self.all_nodes: raise ValueError("Node not in this architecture") - parents = set() + descendants = set() for other in self.all_nodes: if (node, other) in self.edges.edge_list: - parents.add(other) - return parents + descendants.add(other) + return descendants - def children(self, node: Node): + def ancestors(self, node: Node): """Returns a set of all nodes to which the input node has a direct edge from""" if node not in self.all_nodes: raise ValueError("Node not in this architecture") - children = set() + ancestors = set() for other in self.all_nodes: if (other, node) in self.edges.edge_list: - children.add(other) - return children - - def sibling_group(self, node: Node): - """Returns a set of siblings of the given node. The given node is included in this set.""" - siblings = set() - for parent in self.parents(node): - for child in self.children(parent): - siblings.add(child) - return siblings - - @property - def shortest_path_dict(self): - g = nx.DiGraph() - for edge in self.edges.edge_list: - g.add_edge(edge[0], edge[1]) - path = nx.all_pairs_shortest_path_length(g) - dpath = {x[0]: x[1] for x in path} - return dpath - - def _parent_position(self, node: Node): - """Returns a tuple of (x_coord, y_coord) giving the location of a node's parent""" - parents = self.parents(node) - if len(parents) == 1: - parent = parents.pop() - else: - raise ValueError("Node has more than one parent") - return parent.position - - @property - def top_nodes(self): - top_nodes = list() - for node in self.all_nodes: - if len(self.parents(node)) == 0: - # This node must be the top level node - top_nodes.append(node) - - return top_nodes - - def number_of_leaves(self, node: Node): - node_leaves = set() - non_leaves = 0 - for leaf_node in self.leaf_nodes: - try: - shortest_path = self.shortest_path_dict[leaf_node][node] - if shortest_path != 0: - node_leaves.add(leaf_node) - except KeyError: - non_leaves += 1 - - if len(node_leaves) == 0: - return 1 - else: - return len(node_leaves) - - @property - def leaf_nodes(self): - leaf_nodes = set() - for node in self.all_nodes: - if len(self.children(node)) == 0: - # This must be a leaf node - leaf_nodes.add(node) - return leaf_nodes + ancestors.add(other) + return ancestors @abstractmethod def propagate(self, time_increment: float): @@ -199,66 +135,6 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, "position, given as a Tuple of length 2") attr = {"pos": f"{node.position[0]},{node.position[1]}!"} self.di_graph.nodes[node].update(attr) - elif self.is_hierarchical: - - print("Hierarchical plotting is used") - - # Find top node and assign location - top_nodes = self.top_nodes - if len(top_nodes) == 1: - top_node = top_nodes[0] - else: - raise ValueError("Graph with more than one top level node provided.") - - top_node.position = (0, 0) - attr = {"pos": f"{top_node.position[0]},{top_node.position[1]}!"} - self.di_graph.nodes[top_node].update(attr) - - plotted_nodes = set() - - # Set of nodes that have been plotted, but need to have parent nodes plotted - layer_nodes = set() - layer_nodes.add(top_node) - plotted_nodes.add(top_node) - - layer = -1 - while len(plotted_nodes) < len(self.all_nodes): - - print(len(plotted_nodes)) - print(len(self.all_nodes)) - - nodes_to_pop = set() - next_layer_nodes = set() - for layer_node in layer_nodes: - - # Find children of the parent node - children = self.children(layer_node) - n_children = len(children) - print(layer_node.label, "n_children = ", n_children) - - # Find number of leaf nodes that descend from the parent - n_parent_leaves = self.number_of_leaves(layer_node) - - # Get parent x_loc - parent_x_loc = layer_node.position[0] - - # Get location of left limit of the range that leaf nodes will be plotted in - l_x_loc = parent_x_loc - n_parent_leaves/2 - left_limit = l_x_loc - - for child in children: - x_loc = left_limit + self.number_of_leaves(child)/2 - left_limit += self.number_of_leaves(child) - child.position = (x_loc, layer) - attr = {"pos": f"{child.position[0]},{child.position[1]}!"} - self.di_graph.nodes[child].update(attr) - next_layer_nodes.add(child) - plotted_nodes.add(child) - nodes_to_pop.add(layer_node) - - layer_nodes = next_layer_nodes - layer -= 1 - dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() dot_split = dot.split('\n') dot_split.insert(1, f"graph [bgcolor={bgcolour}]") @@ -281,42 +157,16 @@ def density(self): that exist in the graph""" num_nodes = len(self.all_nodes) num_edges = len(self.edges) - architecture_density = num_edges / ((num_nodes * (num_nodes - 1)) / 2) + architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) return architecture_density @property def is_hierarchical(self): """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" - # if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: - no_parents = 0 - one_parent = 0 - multiple_parents = 0 - for node in self.all_nodes: - if len(self.parents(node)) == 0: - no_parents += 1 - elif len(self.parents(node)) == 1: - one_parent += 1 - elif len(self.parents(node)) > 1: - multiple_parents += 1 - - if multiple_parents == 0 and no_parents == 1: - return True - else: + if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: return False - - @property - def is_centralised(self): - n_parents = 0 - one_parent = 0 - multiple_parents = 0 - for node in self.all_nodes: - if len(node.children) == 0: - n_parents += 1 - - if n_parents == 0: - return True else: - return False + return True @property def is_connected(self): @@ -332,7 +182,7 @@ def __len__(self): @property def fully_propagated(self): """Checks if all data for each node have been transferred - to its parents. With zero latency, this should be the case after running propagate""" + to its descendants. With zero latency, this should be the case after running propagate""" for edge in self.edges.edges: if len(edge.unsent_data) != 0: return False @@ -350,6 +200,8 @@ def __init__(self, *args, **kwargs): for node in self.all_nodes: if isinstance(node, RepeaterNode): raise TypeError("Information architecture should not contain any repeater nodes") + for fusion_node in self.fusion_nodes: + fusion_node.tracker.set_time(self.current_time) def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: @@ -381,8 +233,8 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd for data in all_detections[sensor_node]: # The sensor acquires its own data instantly sensor_node.update(data.timestamp, data.timestamp, - DataPiece(sensor_node, sensor_node, data, - data.timestamp), "created") + DataPiece(sensor_node, sensor_node, data, data.timestamp), + 'created') return all_detections @@ -410,21 +262,20 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): x.start() self.current_time += timedelta(seconds=time_increment) - for node in self.fusion_nodes: - node.track_fusion_tracker.current_time = self.current_time + for fusion_node in self.fusion_nodes: + fusion_node.tracker.set_time(self.current_time) class NetworkArchitecture(Architecture): """The architecture for how data is propagated through the network. Node A is connected " "to Node B if and only if A sends its data through B. """ - def propagate(self, time_increment: float): # Still have to deal with latency/bandwidth self.current_time += timedelta(seconds=time_increment) for node in self.all_nodes: - for parent in self.parents(node): + for descendant in self.descendants(node): for data in node.data_held: - parent.update(self.current_time, data) + descendant.update(self.current_time, data) class CombinedArchitecture(Base): diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index 457749858..fb9e578a7 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -1,5 +1,4 @@ import datetime -import numpy as np from .base import Tracker from ..base import Property @@ -7,26 +6,14 @@ from stonesoup.dataassociator.tracktotrack import TrackToTrackCounting from stonesoup.reader.base import DetectionReader from stonesoup.types.detection import Detection +from stonesoup.types.hypothesis import Hypothesis from stonesoup.types.track import Track from stonesoup.tracker.pointprocess import PointProcessMultiTargetTracker -from stonesoup.tracker.simple import MultiTargetTracker from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder -from stonesoup.architecture.edge import FusionQueue - -from stonesoup.models.transition.categorical import MarkovianTransitionModel -from stonesoup.types.state import CategoricalState -from stonesoup.predictor.categorical import HMMPredictor -from stonesoup.updater.categorical import HMMUpdater -from stonesoup.hypothesiser.categorical import HMMHypothesiser -from stonesoup.dataassociator.neighbour import GNNWith2DAssignment -from stonesoup.initiator.categorical import SimpleCategoricalMeasurementInitiator -from stonesoup.deleter.time import UpdateTimeStepsDeleter -from stonesoup.tracker.tests.conftest import detector class DummyDetector(DetectionReader): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) self.current = kwargs['current'] @BufferedGenerator.generator_method @@ -38,66 +25,51 @@ class SimpleFusionTracker(Tracker): # implement tracks method """Presumes data from this node are detections, and from every other node are tracks Acts as a wrapper around a base tracker. Track is fixed after the sliding window. It exists within it, but the States may change. """ - base_tracker: Tracker = Property(default=None,#MultiTargetTracker( - # initiator=SimpleCategoricalMeasurementInitiator(prior_state=CategoricalState([1 / 2, 1 / 2], categories=['Friendly', 'Enemy']), updater=HMMUpdater()), - # deleter=UpdateTimeStepsDeleter(2), - # detector=, - # data_associator=GNNWith2DAssignment(HMMHypothesiser(predictor=HMMPredictor(MarkovianTransitionModel(transition_matrix=np.array([[0.6, 0.4], [0.39, 0.61]]))), updater=HMMUpdater())), - # updater=HMMUpdater()), - doc="Tracker given to the fusion node") - sliding_window: int = Property(default=30, + base_tracker: Tracker = Property(doc="Tracker given to the fusion node") + sliding_window = Property(default=30, doc="The number of time steps before the result is fixed") - queue: FusionQueue = Property(default=None, doc="Queue which feeds in data") - current_time: datetime.datetime = Property(default=datetime.datetime(2000, 1, 1, 0, 0, 0, 1)) - detector: DetectionReader = Property(doc= "Detection reader to read detections from the queue") + queue = Property(default=None, doc="Queue which feeds in data") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tracks = set() + self._current_time = None + + def set_time(self, time): + self._current_time = time @property def tracks(self): return self._tracks - def __iter__(self): - self.detector_iter = iter(self.detector) - return super().__iter__() - def __next__(self): - if self.current_time == datetime.datetime(2000, 1, 1, 0, 0, 0, 1): - raise ValueError("current_time tracker property hasn't updated to match Architecture " - "current_time") - # Get data in time window - - # I think this block of code is equivalent to a detection reader? - data_in_window = list() - for data_piece in self.queue: - if (self.current_time - datetime.timedelta(seconds=self.sliding_window) < - data_piece.time_arrived <= self.current_time): - data_in_window.append(data_piece) - cdets = set() - ctracks = set() - for data_piece in data_in_window: - if isinstance(data_piece, Detection): - cdets.add(data_piece) - elif isinstance(data_piece, Track): - ctracks.add(data_piece) - - # run our tracker on our detections - tracks = set() - for time, ctracks in self.base_tracker: - tracks.update(ctracks) - - # Take the tracks and combine them, accounting for the fact that they might not all be as - # up-to-date as one another - # for tracks in [ctracks]: - # dummy_detector = DummyDetector(current=[time, tracks]) - # self.track_fusion_tracker.detector = Tracks2GaussianDetectionFeeder(dummy_detector) - # self.track_fusion_tracker.__iter__() - # _, tracks = next(self.track_fusion_tracker) - # self.track_fusion_tracker.update(tracks) - - return time, self.tracks + data_piece = self.queue.get() + if (self._current_time - data_piece.time_arrived).total_seconds() > self.sliding_window: + # data not in window + return + if data_piece.time_arrived > self._current_time: + raise ValueError("Not sure this should happen... check this") + + if isinstance(data_piece.data, Detection): + return next(self.base_tracker) # this won't work probably :( + # Need to feed in self.queue as base_tracker.detector_iter + # Also need to give the base tracker our tracks to treat as its own + elif isinstance(data_piece.data, Track): + # Must take account if one track has been fused together already + + # like this? + # for tracks in [ctracks]: + # dummy_detector = DummyDetector(current=[time, tracks]) + # self.track_fusion_tracker.detector = Tracks2GaussianDetectionFeeder(dummy_detector) + # self.track_fusion_tracker.__iter__() + # _, tracks = next(self.track_fusion_tracker) + # self.track_fusion_tracker.update(tracks) + # + # return time, self.tracks + elif isinstance(data_piece.data, Hypothesis): + # do something + else: + raise TypeError(f"Data piece contained an incompatible type: {type(data_piece.data)}") # Don't edit anything that comes before the current time - the sliding window. That's fixed forever. From f20967b06ae162842c2e726b5a4b54eefd448a01 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Fri, 28 Jul 2023 11:34:42 +0100 Subject: [PATCH 047/170] sketch of fusion tracker --- stonesoup/tracker/fusion.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index fb9e578a7..4a15c304a 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -12,8 +12,19 @@ from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder +def FusionTracker(Tracker): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tracks = set() + self._current_time = None + + def set_time(self, time): + self._current_time = time + + class DummyDetector(DetectionReader): def __init__(self, *args, **kwargs): + super().__init__(self, *args, **kwargs) self.current = kwargs['current'] @BufferedGenerator.generator_method @@ -21,7 +32,7 @@ def detections_gen(self): yield self.current -class SimpleFusionTracker(Tracker): # implement tracks method +class SimpleFusionTracker(FusionTracker): # implement tracks method """Presumes data from this node are detections, and from every other node are tracks Acts as a wrapper around a base tracker. Track is fixed after the sliding window. It exists within it, but the States may change. """ @@ -29,14 +40,8 @@ class SimpleFusionTracker(Tracker): # implement tracks method sliding_window = Property(default=30, doc="The number of time steps before the result is fixed") queue = Property(default=None, doc="Queue which feeds in data") + track_fusion_tracker = Property(doc="Tracker for fusing of multiple tracks together") - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._tracks = set() - self._current_time = None - - def set_time(self, time): - self._current_time = time @property def tracks(self): @@ -55,6 +60,7 @@ def __next__(self): # Need to feed in self.queue as base_tracker.detector_iter # Also need to give the base tracker our tracks to treat as its own elif isinstance(data_piece.data, Track): + pass # Must take account if one track has been fused together already # like this? @@ -66,10 +72,5 @@ def __next__(self): # self.track_fusion_tracker.update(tracks) # # return time, self.tracks - elif isinstance(data_piece.data, Hypothesis): - # do something else: raise TypeError(f"Data piece contained an incompatible type: {type(data_piece.data)}") - - - # Don't edit anything that comes before the current time - the sliding window. That's fixed forever. From 2809e5dd1cb777e9db34242f28a68255125fbb08 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 21 Aug 2023 17:32:50 +0100 Subject: [PATCH 048/170] Convert stonesoup.architectures from a directory to a Python Package. Add basic test for hierarchical plot. Move function _dict_set() into its own file. --- stonesoup/architecture/__init__.py | 0 stonesoup/architecture/architecture.py | 188 ++++++- stonesoup/architecture/edge.py | 4 +- stonesoup/architecture/functions.py | 26 + stonesoup/architecture/node.py | 27 +- stonesoup/architecture/tests/__init__.py | 0 .../architecture/tests/test_architecture.py | 89 ++++ stonesoup/architecture2/architecture.py | 483 ++++++++++++++++++ stonesoup/architecture2/edge.py | 194 +++++++ stonesoup/architecture2/node.py | 171 +++++++ stonesoup/architecture2/tests/__init__.py | 0 .../architecture2/tests/test_architecture.py | 61 +++ 12 files changed, 1201 insertions(+), 42 deletions(-) create mode 100644 stonesoup/architecture/__init__.py create mode 100644 stonesoup/architecture/functions.py create mode 100644 stonesoup/architecture/tests/__init__.py create mode 100644 stonesoup/architecture/tests/test_architecture.py create mode 100644 stonesoup/architecture2/architecture.py create mode 100644 stonesoup/architecture2/edge.py create mode 100644 stonesoup/architecture2/node.py create mode 100644 stonesoup/architecture2/tests/__init__.py create mode 100644 stonesoup/architecture2/tests/test_architecture.py diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py index 81bf2ea8f..ff378c544 100644 --- a/stonesoup/architecture/architecture.py +++ b/stonesoup/architecture/architecture.py @@ -1,6 +1,6 @@ from abc import abstractmethod from ..base import Base, Property -from .node import Node, FusionNode, SensorNode, RepeaterNode +from .node import Node, SensorNode, RepeaterNode #, FusionNode from .edge import Edge, Edges, DataPiece from ..types.groundtruth import GroundTruthPath from ..types.detection import TrueDetection, Detection, Clutter @@ -18,6 +18,7 @@ class Architecture(Base): doc="An Edges object containing all edges. For A to be connected to B we would have an " "Edge with edge_pair=(A, B) in this object.") current_time: datetime = Property( + default=datetime.now(), doc="The time which the instance is at for the purpose of simulation. " "This is increased by the propagate method. This should be set to the earliest timestep" " from the ground truth") @@ -30,6 +31,7 @@ class Architecture(Base): doc="If True, the undirected version of the graph must be connected, ie. all nodes should " "be connected via some path. Set this to False to allow an unconnected architecture. " "Default is True") + # Below is no longer required with changes to plot - didn't delete in case we want to revert # to previous method # font_size: int = Property( @@ -66,25 +68,86 @@ def __init__(self, *args, **kwargs): "height": f"{node.node_dim[1]}", "fixedsize": True} self.di_graph.nodes[node].update(attr) - def descendants(self, node: Node): + def parents(self, node: Node): """Returns a set of all nodes to which the input node has a direct edge to""" if node not in self.all_nodes: raise ValueError("Node not in this architecture") - descendants = set() + parents = set() for other in self.all_nodes: if (node, other) in self.edges.edge_list: - descendants.add(other) - return descendants + parents.add(other) + return parents - def ancestors(self, node: Node): + def children(self, node: Node): """Returns a set of all nodes to which the input node has a direct edge from""" if node not in self.all_nodes: raise ValueError("Node not in this architecture") - ancestors = set() + children = set() for other in self.all_nodes: if (other, node) in self.edges.edge_list: - ancestors.add(other) - return ancestors + children.add(other) + return children + + def sibling_group(self, node: Node): + """Returns a set of siblings of the given node. The given node is included in this set.""" + siblings = set() + for parent in self.parents(node): + for child in self.children(parent): + siblings.add(child) + return siblings + + @property + def shortest_path_dict(self): + g = nx.DiGraph() + for edge in self.edges.edge_list: + g.add_edge(edge[0], edge[1]) + path = nx.all_pairs_shortest_path_length(g) + dpath = {x[0]: x[1] for x in path} + return dpath + + def _parent_position(self, node: Node): + """Returns a tuple of (x_coord, y_coord) giving the location of a node's parent""" + parents = self.parents(node) + if len(parents) == 1: + parent = parents.pop() + else: + raise ValueError("Node has more than one parent") + return parent.position + + @property + def top_nodes(self): + top_nodes = list() + for node in self.all_nodes: + if len(self.parents(node)) == 0: + # This node must be the top level node + top_nodes.append(node) + + return top_nodes + + def number_of_leaves(self, node: Node): + node_leaves = set() + non_leaves = 0 + for leaf_node in self.leaf_nodes: + try: + shortest_path = self.shortest_path_dict[leaf_node][node] + if shortest_path != 0: + node_leaves.add(leaf_node) + except KeyError: + non_leaves += 1 + + if len(node_leaves) == 0: + return 1 + else: + return len(node_leaves) + + @property + def leaf_nodes(self): + leaf_nodes = set() + for node in self.all_nodes: + if len(self.children(node)) == 0: + # This must be a leaf node + leaf_nodes.add(node) + return leaf_nodes @abstractmethod def propagate(self, time_increment: float): @@ -135,6 +198,74 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, "position, given as a Tuple of length 2") attr = {"pos": f"{node.position[0]},{node.position[1]}!"} self.di_graph.nodes[node].update(attr) + elif self.is_hierarchical: + + # Find top node and assign location + top_nodes = self.top_nodes + if len(top_nodes) == 1: + top_node = top_nodes[0] + else: + raise ValueError("Graph with more than one top level node provided.") + + top_node.position = (0, 0) + attr = {"pos": f"{top_node.position[0]},{top_node.position[1]}!"} + self.di_graph.nodes[top_node].update(attr) + + # Set of nodes that have been plotted / had their positions updated + plotted_nodes = set() + plotted_nodes.add(top_node) + + # Set of nodes that have been plotted, but need to have parent nodes plotted + layer_nodes = set() + layer_nodes.add(top_node) + + # Initialise a layer count + layer = -1 + while len(plotted_nodes) < len(self.all_nodes): + + # Initialse an empty set to store nodes to be considered in the next iteration + next_layer_nodes = set() + + # Iterate through nodes on the current layer (nodes that have been plotted but have + # child nodes that are not plotted) + for layer_node in layer_nodes: + + # Find children of the parent node + children = self.children(layer_node) + + # Find number of leaf nodes that descend from the parent + n_parent_leaves = self.number_of_leaves(layer_node) + + # Get parent x_loc + parent_x_loc = layer_node.position[0] + + # Get location of left limit of the range that leaf nodes will be plotted in + l_x_loc = parent_x_loc - n_parent_leaves/2 + left_limit = l_x_loc + + for child in children: + # Calculate x_loc of the child node + x_loc = left_limit + self.number_of_leaves(child)/2 + + # Update the left limit + left_limit += self.number_of_leaves(child) + + # Update the position of the child node + child.position = (x_loc, layer) + attr = {"pos": f"{child.position[0]},{child.position[1]}!"} + self.di_graph.nodes[child].update(attr) + + # Add child node to list of nodes to be considered in next iteration, and + # to list of nodes that have been plotted + next_layer_nodes.add(child) + plotted_nodes.add(child) + + # Set list of nodes to be considered next iteration + layer_nodes = next_layer_nodes + + # Update layer count for correct y location + layer -= 1 + dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() dot_split = dot.split('\n') dot_split.insert(1, f"graph [bgcolor={bgcolour}]") @@ -157,16 +288,42 @@ def density(self): that exist in the graph""" num_nodes = len(self.all_nodes) num_edges = len(self.edges) - architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) + architecture_density = num_edges / ((num_nodes * (num_nodes - 1)) / 2) return architecture_density @property def is_hierarchical(self): """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" - if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: - return False + # if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: + no_parents = 0 + one_parent = 0 + multiple_parents = 0 + for node in self.all_nodes: + if len(self.parents(node)) == 0: + no_parents += 1 + elif len(self.parents(node)) == 1: + one_parent += 1 + elif len(self.parents(node)) > 1: + multiple_parents += 1 + + if multiple_parents == 0 and no_parents == 1: + return True else: + return False + + @property + def is_centralised(self): + n_parents = 0 + one_parent = 0 + multiple_parents = 0 + for node in self.all_nodes: + if len(node.children) == 0: + n_parents += 1 + + if n_parents == 0: return True + else: + return False @property def is_connected(self): @@ -182,7 +339,7 @@ def __len__(self): @property def fully_propagated(self): """Checks if all data for each node have been transferred - to its descendants. With zero latency, this should be the case after running propagate""" + to its parents. With zero latency, this should be the case after running propagate""" for edge in self.edges.edges: if len(edge.unsent_data) != 0: return False @@ -269,13 +426,14 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): class NetworkArchitecture(Architecture): """The architecture for how data is propagated through the network. Node A is connected " "to Node B if and only if A sends its data through B. """ + def propagate(self, time_increment: float): # Still have to deal with latency/bandwidth self.current_time += timedelta(seconds=time_increment) for node in self.all_nodes: - for descendant in self.descendants(node): + for parent in self.parents(node): for data in node.data_held: - descendant.update(self.current_time, data) + parent.update(self.current_time, data) class CombinedArchitecture(Base): diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 98583c63f..2cc69ebda 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -1,9 +1,11 @@ from __future__ import annotations from ..base import Base, Property +from ..types.time import TimeRange from ..types.track import Track from ..types.detection import Detection from ..types.hypothesis import Hypothesis +from .functions import _dict_set from typing import Union, Tuple, List, TYPE_CHECKING from datetime import datetime, timedelta @@ -88,7 +90,7 @@ def update_messages(self, current_time): for time, message in to_remove: self.messages_held['pending'][time].remove(message) - def _failed(self, current_time, duration): + def failed(self, current_time, duration): """Keeps track of when this edge was failed using the time_ranges_failed property. """ end_time = current_time + timedelta(duration) self.time_ranges_failed.append(TimeRange(current_time, end_time)) diff --git a/stonesoup/architecture/functions.py b/stonesoup/architecture/functions.py new file mode 100644 index 000000000..0e8872684 --- /dev/null +++ b/stonesoup/architecture/functions.py @@ -0,0 +1,26 @@ +def _dict_set(my_dict, value, key1, key2=None): + """Utility function to add value to my_dict at the specified key(s) + Returns True iff the set increased in size, ie the value was new to its position""" + if not my_dict: + if key2: + my_dict = {key1: {key2: {value}}} + else: + my_dict = {key1: {value}} + elif key2: + if key1 in my_dict: + if key2 in my_dict[key1]: + old_len = len(my_dict[key1][key2]) + my_dict[key1][key2].add(value) + return len(my_dict[key1][key2]) == old_len + 1, my_dict + else: + my_dict[key1][key2] = {value} + else: + my_dict[key1] = {key2: {value}} + else: + if key1 in my_dict: + old_len = len(my_dict[key1]) + my_dict[key1].add(value) + return len(my_dict[key1]) == old_len + 1, my_dict + else: + my_dict[key1] = {value} + return True, my_dict diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 2a677854a..d95822140 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -8,6 +8,7 @@ from ..tracker.fusion import SimpleFusionTracker from datetime import datetime from typing import Tuple +from .functions import _dict_set class Node(Base): @@ -146,29 +147,3 @@ def __init__(self, *args, **kwargs): self.node_dim = (0.5, 0.3) -def _dict_set(my_dict, value, key1, key2=None): - """Utility function to add value to my_dict at the specified key(s) - Returns True iff the set increased in size, ie the value was new to its position""" - if not my_dict: - if key2: - my_dict = {key1: {key2: {value}}} - else: - my_dict = {key1: {value}} - elif key2: - if key1 in my_dict: - if key2 in my_dict[key1]: - old_len = len(my_dict[key1][key2]) - my_dict[key1][key2].add(value) - return len(my_dict[key1][key2]) == old_len + 1, my_dict - else: - my_dict[key1][key2] = {value} - else: - my_dict[key1] = {key2: {value}} - else: - if key1 in my_dict: - old_len = len(my_dict[key1]) - my_dict[key1].add(value) - return len(my_dict[key1]) == old_len + 1, my_dict - else: - my_dict[key1] = {value} - return True, my_dict diff --git a/stonesoup/architecture/tests/__init__.py b/stonesoup/architecture/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py new file mode 100644 index 000000000..323bfddd8 --- /dev/null +++ b/stonesoup/architecture/tests/test_architecture.py @@ -0,0 +1,89 @@ +import pytest + +import numpy as np + + +from ..architecture import InformationArchitecture +from ..edge import Edge, Edges +from ..node import RepeaterNode, SensorNode +from ...sensor.categorical import HMMSensor +from ...models.measurement.categorical import MarkovianMeasurementModel + +# @pytest.fixture +# def dependencies(): +# E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) +# [0.19, 0.3], # P(medium | bike), P(medium | car) +# [0.01, 0.6]]) # P(large | bike), P(large | car) +# +# model = MarkovianMeasurementModel(emission_matrix=E, +# measurement_categories=['small', 'medium', 'large']) +# +# hmm_sensor = HMMSensor(measurement_model=model) +# +# +# node1 = SensorNode(sensor=hmm_sensor, label='1') +# node2 = SensorNode(sensor=hmm_sensor, label='2') +# node3 = SensorNode(sensor=hmm_sensor, label='3') +# node4 = SensorNode(sensor=hmm_sensor, label='4') +# node5 = SensorNode(sensor=hmm_sensor, label='5') +# node6 = SensorNode(sensor=hmm_sensor, label='6') +# node7 = SensorNode(sensor=hmm_sensor, label='7') +# +# nodes = [node1, node2, node3, node4, node5, node6, node7] +# +# edges = Edges( +# [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), +# Edge((node6, node3)), Edge((node7, node6))]) +# +# fixtures = dict() +# fixtures["Edges"] = edges +# fixtures["Nodes"] = nodes +# +# return fixtures + + +def test_hierarchical_plot(): + #fixtures = fixtures() + + # + # edges = fixtures["Edges"] + # nodes = fixtures["Nodes"] + + E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) + [0.19, 0.3], # P(medium | bike), P(medium | car) + [0.01, 0.6]]) # P(large | bike), P(large | car) + + model = MarkovianMeasurementModel(emission_matrix=E, + measurement_categories=['small', 'medium', 'large']) + + hmm_sensor = HMMSensor(measurement_model=model) + + node1 = SensorNode(sensor=hmm_sensor, label='1') + node2 = SensorNode(sensor=hmm_sensor, label='2') + node3 = SensorNode(sensor=hmm_sensor, label='3') + node4 = SensorNode(sensor=hmm_sensor, label='4') + node5 = SensorNode(sensor=hmm_sensor, label='5') + node6 = SensorNode(sensor=hmm_sensor, label='6') + node7 = SensorNode(sensor=hmm_sensor, label='7') + + nodes = [node1, node2, node3, node4, node5, node6, node7] + + edges = Edges( + [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), + Edge((node6, node3)), Edge((node7, node6))]) + + arch = InformationArchitecture(edges=edges) + + arch.plot() + + assert nodes[1].position == (0, 0) + assert nodes[2].position == (-0.5, -1) + assert nodes[3].position == (1.0, -1) + assert nodes[4].position == (0.0, -2) + assert nodes[5].position == (-1.0, -2) + assert nodes[6].position == (1.0, -2) + assert nodes[7].position == (1.0, -3) + + +def test_simple_information_architecture(): + arch = InformationArchitecture() diff --git a/stonesoup/architecture2/architecture.py b/stonesoup/architecture2/architecture.py new file mode 100644 index 000000000..8e6cab26c --- /dev/null +++ b/stonesoup/architecture2/architecture.py @@ -0,0 +1,483 @@ +from abc import abstractmethod +from ..base import Base, Property +from .node import Node, FusionNode, SensorNode, RepeaterNode +from .edge import Edge, Edges, DataPiece +from ..types.groundtruth import GroundTruthPath +from ..types.detection import TrueDetection, Detection, Clutter + +from typing import List, Collection, Tuple, Set, Union, Dict +import numpy as np +import networkx as nx +import graphviz +from string import ascii_uppercase as auc +from datetime import datetime, timedelta +import threading + +class Architecture(Base): + edges: Edges = Property( + doc="An Edges object containing all edges. For A to be connected to B we would have an " + "Edge with edge_pair=(A, B) in this object.") + current_time: datetime = Property( + default=datetime.now(), + doc="The time which the instance is at for the purpose of simulation. " + "This is increased by the propagate method. This should be set to the earliest timestep" + " from the ground truth") + name: str = Property( + default=None, + doc="A name for the architecture, to be used to name files and/or title plots. Default is " + "the class name") + force_connected: bool = Property( + default=True, + doc="If True, the undirected version of the graph must be connected, ie. all nodes should " + "be connected via some path. Set this to False to allow an unconnected architecture. " + "Default is True") + + # Below is no longer required with changes to plot - didn't delete in case we want to revert + # to previous method + # font_size: int = Property( + # default=8, + # doc='Font size for node labels') + # node_dim: tuple = Property( + # default=(0.5, 0.5), + # doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.name: + self.name = type(self).__name__ + if not self.current_time: + self.current_time = datetime.now() + + self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) + + if self.force_connected and not self.is_connected and len(self) > 0: + raise ValueError("The graph is not connected. Use force_connected=False, " + "if you wish to override this requirement") + + # Set attributes such as label, colour, shape, etc for each node + last_letters = {'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', + 'RepeaterNode': ''} + for node in self.di_graph.nodes: + if node.label: + label = node.label + else: + label, last_letters = _default_label(node, last_letters) + node.label = label + attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", + "fontsize": f"{node.font_size}", "width": f"{node.node_dim[0]}", + "height": f"{node.node_dim[1]}", "fixedsize": True} + self.di_graph.nodes[node].update(attr) + + def parents(self, node: Node): + """Returns a set of all nodes to which the input node has a direct edge to""" + if node not in self.all_nodes: + raise ValueError("Node not in this architecture") + parents = set() + for other in self.all_nodes: + if (node, other) in self.edges.edge_list: + parents.add(other) + return parents + + def children(self, node: Node): + """Returns a set of all nodes to which the input node has a direct edge from""" + if node not in self.all_nodes: + raise ValueError("Node not in this architecture") + children = set() + for other in self.all_nodes: + if (other, node) in self.edges.edge_list: + children.add(other) + return children + + def sibling_group(self, node: Node): + """Returns a set of siblings of the given node. The given node is included in this set.""" + siblings = set() + for parent in self.parents(node): + for child in self.children(parent): + siblings.add(child) + return siblings + + @property + def shortest_path_dict(self): + g = nx.DiGraph() + for edge in self.edges.edge_list: + g.add_edge(edge[0], edge[1]) + path = nx.all_pairs_shortest_path_length(g) + dpath = {x[0]: x[1] for x in path} + return dpath + + def _parent_position(self, node: Node): + """Returns a tuple of (x_coord, y_coord) giving the location of a node's parent""" + parents = self.parents(node) + if len(parents) == 1: + parent = parents.pop() + else: + raise ValueError("Node has more than one parent") + return parent.position + + @property + def top_nodes(self): + top_nodes = list() + for node in self.all_nodes: + if len(self.parents(node)) == 0: + # This node must be the top level node + top_nodes.append(node) + + return top_nodes + + def number_of_leaves(self, node: Node): + node_leaves = set() + non_leaves = 0 + for leaf_node in self.leaf_nodes: + try: + shortest_path = self.shortest_path_dict[leaf_node][node] + if shortest_path != 0: + node_leaves.add(leaf_node) + except KeyError: + non_leaves += 1 + + if len(node_leaves) == 0: + return 1 + else: + return len(node_leaves) + + @property + def leaf_nodes(self): + leaf_nodes = set() + for node in self.all_nodes: + if len(self.children(node)) == 0: + # This must be a leaf node + leaf_nodes.add(node) + return leaf_nodes + + @abstractmethod + def propagate(self, time_increment: float): + raise NotImplementedError + + @property + def all_nodes(self): + return set(self.di_graph.nodes) + + @property + def sensor_nodes(self): + sensor_nodes = set() + for node in self.all_nodes: + if isinstance(node, SensorNode): + sensor_nodes.add(node) + return sensor_nodes + + @property + def fusion_nodes(self): + processing = set() + for node in self.all_nodes: + if isinstance(node, FusionNode): + processing.add(node) + return processing + + def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, + bgcolour="lightgray", node_style="filled"): + """Creates a pdf plot of the directed graph and displays it + + :param dir_path: The path to save the pdf and .gv files to + :param filename: Name to call the associated files + :param use_positions: + :param plot_title: If a string is supplied, makes this the title of the plot. If True, uses + the name attribute of the graph to title the plot. If False, no title is used. + Default is False + :param bgcolour: String containing the background colour for the plot. + Default is "lightgray". See graphviz attributes for more information. + One alternative is "white" + :param node_style: String containing the node style for the plot. + Default is "filled". See graphviz attributes for more information. + One alternative is "solid" + :return: + """ + if use_positions: + for node in self.di_graph.nodes: + if not isinstance(node.position, Tuple): + raise TypeError("If use_positions is set to True, every node must have a " + "position, given as a Tuple of length 2") + attr = {"pos": f"{node.position[0]},{node.position[1]}!"} + self.di_graph.nodes[node].update(attr) + elif self.is_hierarchical: + + # Find top node and assign location + top_nodes = self.top_nodes + if len(top_nodes) == 1: + top_node = top_nodes[0] + else: + raise ValueError("Graph with more than one top level node provided.") + + top_node.position = (0, 0) + attr = {"pos": f"{top_node.position[0]},{top_node.position[1]}!"} + self.di_graph.nodes[top_node].update(attr) + + # Set of nodes that have been plotted / had their positions updated + plotted_nodes = set() + plotted_nodes.add(top_node) + + # Set of nodes that have been plotted, but need to have parent nodes plotted + layer_nodes = set() + layer_nodes.add(top_node) + + # Initialise a layer count + layer = -1 + while len(plotted_nodes) < len(self.all_nodes): + + # Initialse an empty set to store nodes to be considered in the next iteration + next_layer_nodes = set() + + # Iterate through nodes on the current layer (nodes that have been plotted but have + # child nodes that are not plotted) + for layer_node in layer_nodes: + + # Find children of the parent node + children = self.children(layer_node) + + # Find number of leaf nodes that descend from the parent + n_parent_leaves = self.number_of_leaves(layer_node) + + # Get parent x_loc + parent_x_loc = layer_node.position[0] + + # Get location of left limit of the range that leaf nodes will be plotted in + l_x_loc = parent_x_loc - n_parent_leaves/2 + left_limit = l_x_loc + + for child in children: + # Calculate x_loc of the child node + x_loc = left_limit + self.number_of_leaves(child)/2 + + # Update the left limit + left_limit += self.number_of_leaves(child) + + # Update the position of the child node + child.position = (x_loc, layer) + attr = {"pos": f"{child.position[0]},{child.position[1]}!"} + self.di_graph.nodes[child].update(attr) + + # Add child node to list of nodes to be considered in next iteration, and + # to list of nodes that have been plotted + next_layer_nodes.add(child) + plotted_nodes.add(child) + + # Set list of nodes to be considered next iteration + layer_nodes = next_layer_nodes + + # Update layer count for correct y location + layer -= 1 + + dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() + dot_split = dot.split('\n') + dot_split.insert(1, f"graph [bgcolor={bgcolour}]") + dot_split.insert(1, f"node [style={node_style}]") + dot = "\n".join(dot_split) + if plot_title: + if plot_title is True: + plot_title = self.name + elif not isinstance(plot_title, str): + raise ValueError("Plot title must be a string, or True") + dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{plot_title}\";" + "}" + if not filename: + filename = self.name + viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') + viz_graph.view() + + @property + def density(self): + """Returns the density of the graph, ie. the proportion of possible edges between nodes + that exist in the graph""" + num_nodes = len(self.all_nodes) + num_edges = len(self.edges) + architecture_density = num_edges / ((num_nodes * (num_nodes - 1)) / 2) + return architecture_density + + @property + def is_hierarchical(self): + """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" + # if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: + no_parents = 0 + one_parent = 0 + multiple_parents = 0 + for node in self.all_nodes: + if len(self.parents(node)) == 0: + no_parents += 1 + elif len(self.parents(node)) == 1: + one_parent += 1 + elif len(self.parents(node)) > 1: + multiple_parents += 1 + + if multiple_parents == 0 and no_parents == 1: + return True + else: + return False + + @property + def is_centralised(self): + n_parents = 0 + one_parent = 0 + multiple_parents = 0 + for node in self.all_nodes: + if len(node.children) == 0: + n_parents += 1 + + if n_parents == 0: + return True + else: + return False + + @property + def is_connected(self): + return nx.is_connected(self.to_undirected) + + @property + def to_undirected(self): + return self.di_graph.to_undirected() + + def __len__(self): + return len(self.di_graph) + + @property + def fully_propagated(self): + """Checks if all data for each node have been transferred + to its parents. With zero latency, this should be the case after running propagate""" + for edge in self.edges.edges: + if len(edge.unsent_data) != 0: + return False + + return True + + +class InformationArchitecture(Architecture): + """The architecture for how information is shared through the network. Node A is " + "connected to Node B if and only if the information A creates by processing and/or " + "sensing is received and opened by B without modification by another node. """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for node in self.all_nodes: + if isinstance(node, RepeaterNode): + raise TypeError("Information architecture should not contain any repeater nodes") + for fusion_node in self.fusion_nodes: + fusion_node.tracker.set_time(self.current_time) + + def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, + **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: + """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ + all_detections = dict() + + # Get rid of ground truths that have not yet happened + # (ie GroundTruthState's with timestamp after self.current_time) + new_ground_truths = set() + for ground_truth_path in ground_truths: + # need an if len(states) == 0 continue condition here? + new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) + + for sensor_node in self.sensor_nodes: + all_detections[sensor_node] = set() + for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): + all_detections[sensor_node].add(detection) + + # Borrowed below from SensorSuite. I don't think it's necessary, but might be something + # we need. If so, will need to define self.attributes_inform + + # attributes_dict = \ + # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) + # for attribute_name in self.attributes_inform} + # + # for detection in all_detections[sensor_node]: + # detection.metadata.update(attributes_dict) + + for data in all_detections[sensor_node]: + # The sensor acquires its own data instantly + sensor_node.update(data.timestamp, data.timestamp, + DataPiece(sensor_node, sensor_node, data, data.timestamp), + 'created') + + return all_detections + + def propagate(self, time_increment: float, failed_edges: Collection = None): + """Performs the propagation of the measurements through the network""" + for edge in self.edges.edges: + if failed_edges and edge in failed_edges: + edge._failed(self.current_time, time_increment) + continue # No data passed along these edges + edge.update_messages(self.current_time) + # fuse goes here? + for data_piece, time_pertaining in edge.unsent_data: + edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) + + # for node in self.processing_nodes: + # node.process() # This should happen when a new message is received + count = 0 + if not self.fully_propagated: + count += 1 + self.propagate(time_increment, failed_edges) + return + + for fuse_node in self.fusion_nodes: + x = threading.Thread(target=fuse_node.fuse) + x.start() + + self.current_time += timedelta(seconds=time_increment) + for fusion_node in self.fusion_nodes: + fusion_node.tracker.set_time(self.current_time) + + +class NetworkArchitecture(Architecture): + """The architecture for how data is propagated through the network. Node A is connected " + "to Node B if and only if A sends its data through B. """ + + def propagate(self, time_increment: float): + # Still have to deal with latency/bandwidth + self.current_time += timedelta(seconds=time_increment) + for node in self.all_nodes: + for parent in self.parents(node): + for data in node.data_held: + parent.update(self.current_time, data) + + +class CombinedArchitecture(Base): + """Contains an information and a network architecture that pertain to the same scenario. """ + information_architecture: InformationArchitecture = Property( + doc="The information architecture for how information is shared. ") + network_architecture: NetworkArchitecture = Property( + doc="The architecture for how data is propagated through the network. Node A is connected " + "to Node B if and only if A sends its data through B. ") + + def propagate(self, time_increment: float): + # First we simulate the network + self.network_architecture.propagate(time_increment) + # Now we want to only pass information along in the information architecture if it + # Was in the information architecture by at least one path. + # Some magic here + failed_edges = [] # return this from n_arch.propagate? + self.information_architecture.propagate(time_increment, failed_edges) + + +def _default_label(node, last_letters): + """Utility function to generate default labels for nodes, where none are given + Takes a node, and a dictionary with the letters last used for each class, + ie `last_letters['Node']` might return 'AA', meaning the last Node was labelled 'Node AA'""" + node_type = type(node).__name__ + type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' + new_letters = _default_letters(type_letters) + last_letters[node_type] = new_letters + return node_type + ' ' + new_letters, last_letters + + +def _default_letters(type_letters) -> str: + if type_letters == '': + return 'A' + count = 0 + letters_list = [*type_letters] + # Move through string from right to left and shift any Z's up to A's + while letters_list[-1 - count] == 'Z': + letters_list[-1 - count] = 'A' + count += 1 + if count == len(letters_list): + return 'A' * (count + 1) + # Shift current letter up by one + current_letter = letters_list[-1 - count] + letters_list[-1 - count] = auc[auc.index(current_letter) + 1] + new_letters = ''.join(letters_list) + return new_letters diff --git a/stonesoup/architecture2/edge.py b/stonesoup/architecture2/edge.py new file mode 100644 index 000000000..4f047d4fb --- /dev/null +++ b/stonesoup/architecture2/edge.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from ..base import Base, Property +from ..types.time import TimeRange +from ..types.track import Track +from ..types.detection import Detection +from ..types.hypothesis import Hypothesis +from node import _dict_set + +from typing import Union, Tuple, List, TYPE_CHECKING +from datetime import datetime, timedelta +from queue import Queue + +if TYPE_CHECKING: + from .node import Node + + +class FusionQueue(Queue): + """A queue from which fusion nodes draw data they have yet to fuse""" + def __init__(self): + super().__init__(maxsize=999999) + + def get_message(self): + value = self.get() + return value + + def set_message(self, value): + self.put(value) + + +class DataPiece(Base): + """A piece of data for use in an architecture. Sent via Messages, and stored in a Node's data_held""" + node: "Node" = Property( + doc="The Node this data piece belongs to") + originator: "Node" = Property( + doc="The node which first created this data, ie by sensing or fusing information together. " + "If the data is simply passed along the chain, the originator remains unchanged. ") + data: Union[Detection, Track, Hypothesis] = Property( + doc="A Detection, Track, or Hypothesis") + time_arrived: datetime = Property( + doc="The time at which this piece of data was received by the Node, either by Message or by sensing.") + track: Track = Property( + doc="The Track in the event of data being a Hypothesis", + default=None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sent_to = set() # all Nodes the data_piece has been sent to, to avoid duplicates + + +class Edge(Base): + """Comprised of two connected Nodes""" + nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (child, parent") + edge_latency: float = Property(doc="The latency stemming from the edge itself, " + "and not either of the nodes", + default=0) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.messages_held = {"pending": {}, # For pending, messages indexed by time sent. + "received": {}} # For received, by time received + self.time_ranges_failed = [] # List of time ranges during which this edge was failed + + def send_message(self, data_piece, time_pertaining, time_sent): + if not isinstance(data_piece, DataPiece): + raise TypeError("Message info must be one of the following types: " + "Detection, Hypothesis or Track") + # Add message to 'pending' dict of edge + message = Message(self, time_pertaining, time_sent, data_piece) + _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) + # ensure message not re-sent + data_piece.sent_to.add(self.nodes[1]) + + def update_messages(self, current_time): + # Check info type is what we expect + to_remove = set() # Needed as we can't change size of a set during iteration + for time in self.messages_held['pending']: + for message in self.messages_held['pending'][time]: + message.update(current_time) + if message.status == 'received': + # Then the latency has passed and message has been received + # Move message from pending to received messages in edge + to_remove.add((time, message)) + _, self.messages_held = _dict_set(self.messages_held, message, + 'received', message.arrival_time) + # Update + message.recipient_node.update(message.time_pertaining, message.arrival_time, + message.data_piece, "unfused") + + for time, message in to_remove: + self.messages_held['pending'][time].remove(message) + + def failed(self, current_time, duration): + """Keeps track of when this edge was failed using the time_ranges_failed property. """ + end_time = current_time + timedelta(duration) + self.time_ranges_failed.append(TimeRange(current_time, end_time)) + + @property + def child(self): + return self.nodes[0] + + @property + def parent(self): + return self.nodes[1] + + @property + def ovr_latency(self): + """Overall latency including the two Nodes and the edge latency.""" + return self.child.latency + self.edge_latency + self.parent.latency + + @property + def unsent_data(self): + """Data held by the child that has not been sent to the parent.""" + unsent = [] + for status in ["fused", "created"]: + for time_pertaining in self.child.data_held[status]: + for data_piece in self.child.data_held[status][time_pertaining]: + if self.parent not in data_piece.sent_to: + unsent.append((data_piece, time_pertaining)) + return unsent + + +class Edges(Base): + """Container class for Edge""" + edges: List[Edge] = Property(doc="List of Edge objects", default=None) + + def add(self, edge): + self.edges.append(edge) + + def get(self, node_pair): + if not isinstance(node_pair, Tuple) and all(isinstance(node, Node) for node in node_pair): + raise TypeError("Must supply a tuple of nodes") + if not len(node_pair) == 2: + raise ValueError("Incorrect tuple length. Must be of length 2") + for edge in self.edges: + if edge.nodes == node_pair: + # Assume this is the only match? + return edge + return None + + @property + def edge_list(self): + """Returns a list of tuples in the form (child, parent)""" + edge_list = [] + for edge in self.edges: + edge_list.append(edge.nodes) + return edge_list + + def __len__(self): + return len(self.edges) + + +class Message(Base): + """A message, containing a piece of information, that gets propagated between two Nodes. + Messages are opened by nodes that are a parent of the node that sent the message""" + edge: Edge = Property( + doc="The directed edge containing the sender and receiver of the message") + time_pertaining: datetime = Property( + doc="The latest time for which the data pertains. For a Detection, this would be the time " + "of the Detection, or for a Track this is the time of the last State in the Track. " + "Different from time_sent when data is passed on that was not generated by this " + "Node's child") + time_sent: datetime = Property( + doc="Time at which the message was sent") + data_piece: DataPiece = Property( + doc="Info that the sent message contains") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.status = "sending" + + @property + def generator_node(self): + return self.edge.child + + @property + def recipient_node(self): + return self.edge.parent + + @property + def arrival_time(self): + # TODO: incorporate failed time ranges here. Not essential for a first PR. Could do with merging of PR #664 + return self.time_sent + timedelta(seconds=self.edge.ovr_latency) + + def update(self, current_time): + progress = (current_time - self.time_sent).total_seconds() + if progress < self.edge.child.latency: + self.status = "sending" + elif progress < self.edge.child.latency + self.edge.edge_latency: + self.status = "transferring" + elif progress < self.edge.ovr_latency: + self.status = "receiving" + else: + self.status = "received" diff --git a/stonesoup/architecture2/node.py b/stonesoup/architecture2/node.py new file mode 100644 index 000000000..6344833e8 --- /dev/null +++ b/stonesoup/architecture2/node.py @@ -0,0 +1,171 @@ +from ..base import Property, Base +from ..sensor.sensor import Sensor +from ..types.detection import Detection +from ..types.hypothesis import Hypothesis +from ..types.track import Track +from ..tracker.base import Tracker +from .edge import DataPiece, FusionQueue +from ..tracker.fusion import FusionTracker +from datetime import datetime +from typing import Tuple + + +class Node(Base): + """Base node class. Should be abstract""" + latency: float = Property( + doc="Contribution to edge latency stemming from this node", + default=0) + label: str = Property( + doc="Label to be displayed on graph", + default=None) + position: Tuple[float] = Property( + default=None, + doc="Cartesian coordinates for node") + colour: str = Property( + default=None, + doc='Colour to be displayed on graph') + shape: str = Property( + default=None, + doc='Shape used to display nodes') + font_size: int = Property( + default=None, + doc='Font size for node labels') + node_dim: tuple = Property( + default=None, + doc='Width and height of nodes for graph icons, default is (0.5, 0.5)') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.data_held = {"fused": {}, "created": {}, "unfused": {}} + + def update(self, time_pertaining, time_arrived, data_piece, category, track=None): + if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): + raise TypeError("Times must be datetime objects") + if not track: + if not isinstance(data_piece.data, Detection) and not isinstance(data_piece.data, Track): + raise TypeError(f"Data provided without accompanying Track must be a Detection or a Track, not a " + f"{type(data_piece.data).__name__}") + new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, time_arrived) + else: + if not isinstance(data_piece.data, Hypothesis): + raise TypeError("Data provided with Track must be a Hypothesis") + new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, time_arrived, track) + + added, self.data_held[category] = _dict_set(self.data_held[category], new_data_piece, time_pertaining) + if isinstance(self, FusionNode): + self.fusion_queue.set_message(new_data_piece) + + return added + + +class SensorNode(Node): + """A node corresponding to a Sensor. Fresh data is created here""" + sensor: Sensor = Property(doc="Sensor corresponding to this node") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = '#1f77b4' + if not self.shape: + self.shape = 'oval' + if not self.font_size: + self.font_size = 5 + if not self.node_dim: + self.node_dim = (0.5, 0.3) + + +class FusionNode(Node): + """A node that does not measure new data, but does process data it receives""" + # feeder probably as well + tracker: FusionTracker = Property( + doc="Tracker used by this Node to fuse together Tracks and Detections") + fusion_queue: FusionQueue = Property( + default=FusionQueue(), + doc="The queue from which this node draws data to be fused") + tracks: set = Property(default=None, + doc="Set of tracks tracked by the fusion node") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = '#006400' + if not self.shape: + self.shape = 'hexagon' + if not self.font_size: + self.font_size = 5 + if not self.node_dim: + self.node_dim = (0.6, 0.3) + self.tracks = set() # Set of tracks this Node has recorded + + def fuse(self): + print("A node be fusin") + # we have a queue. + data = self.fusion_queue.get_message() + + # Sort detections and tracks and group by time + + if data: + # track it + print("there's data") + for time, track in self.tracker: + self.tracks.update(track) + else: + print("no data") + return + + +class SensorFusionNode(SensorNode, FusionNode): + """A node that is both a sensor and also processes data""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.colour in ['#006400', '#1f77b4']: + self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating + if self.shape in ['oval', 'hexagon']: + self.shape = 'rectangle' + if not self.font_size: + self.font_size = 5 + if not self.node_dim: + self.node_dim = (0.1, 0.3) + + +class RepeaterNode(Node): + """A node which simply passes data along to others, without manipulating the data itself. """ + # Latency property could go here + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.colour: + self.colour = '#ff7f0e' + if not self.shape: + self.shape = 'circle' + if not self.font_size: + self.font_size = 5 + if not self.node_dim: + self.node_dim = (0.5, 0.3) + + +def _dict_set(my_dict, value, key1, key2=None): + """Utility function to add value to my_dict at the specified key(s) + Returns True iff the set increased in size, ie the value was new to its position""" + if not my_dict: + if key2: + my_dict = {key1: {key2: {value}}} + else: + my_dict = {key1: {value}} + elif key2: + if key1 in my_dict: + if key2 in my_dict[key1]: + old_len = len(my_dict[key1][key2]) + my_dict[key1][key2].add(value) + return len(my_dict[key1][key2]) == old_len + 1, my_dict + else: + my_dict[key1][key2] = {value} + else: + my_dict[key1] = {key2: {value}} + else: + if key1 in my_dict: + old_len = len(my_dict[key1]) + my_dict[key1].add(value) + return len(my_dict[key1]) == old_len + 1, my_dict + else: + my_dict[key1] = {value} + return True, my_dict diff --git a/stonesoup/architecture2/tests/__init__.py b/stonesoup/architecture2/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/stonesoup/architecture2/tests/test_architecture.py b/stonesoup/architecture2/tests/test_architecture.py new file mode 100644 index 000000000..671f2164f --- /dev/null +++ b/stonesoup/architecture2/tests/test_architecture.py @@ -0,0 +1,61 @@ +import pytest + +import numpy as np + + +from ..architecture import InformationArchitecture +from ..edge import Edge, Edges +from ..node import RepeaterNode, SensorNode +from ...sensor.categorical import HMMSensor +from ...models.measurement.categorical import MarkovianMeasurementModel + +@pytest.fixture +def dependencies(): + E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) + [0.19, 0.3], # P(medium | bike), P(medium | car) + [0.01, 0.6]]) # P(large | bike), P(large | car) + + model = MarkovianMeasurementModel(emission_matrix=E, + measurement_categories=['small', 'medium', 'large']) + + hmm_sensor = HMMSensor(measurement_model=model) + + + node1 = SensorNode(sensor=hmm_sensor, label='1') + node2 = SensorNode(sensor=hmm_sensor, label='2') + node3 = SensorNode(sensor=hmm_sensor, label='3') + node4 = SensorNode(sensor=hmm_sensor, label='4') + node5 = SensorNode(sensor=hmm_sensor, label='5') + node6 = SensorNode(sensor=hmm_sensor, label='6') + node7 = SensorNode(sensor=hmm_sensor, label='7') + + nodes = [node1, node2, node3, node4, node5, node6, node7] + + edges = Edges( + [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), + Edge((node6, node3)), Edge((node7, node6))]) + + fixtures = dict() + fixtures["Edges"] = edges + fixtures["Nodes"] = nodes + + return fixtures + + +def test_hierarchical_plot(fixtures): + fixtures = fixtures() + edges = fixtures["Edges"] + nodes = fixtures["Nodes"] + + arch = InformationArchitecture(edges=edges) + + arch.plot() + + assert nodes[1].position == (0, 0) + assert nodes[2].position == (-0.5, -1) + assert nodes[3].position == (1.0, -1) + assert nodes[4].position == (0.0, -2) + assert nodes[5].position == (-1.0, -2) + assert nodes[6].position == (1.0, -2) + assert nodes[7].position == (1.0, -3) + From 8007d407752a306b7ee0a871ef9554b453b7f144 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 22 Aug 2023 10:37:39 +0100 Subject: [PATCH 049/170] Add tests for architecture.py, small fixes to other architecture files to make tests work --- stonesoup/architecture/architecture.py | 7 +- stonesoup/architecture/edge.py | 6 + .../architecture/tests/test_architecture.py | 105 +++++++++--------- stonesoup/tracker/fusion.py | 13 ++- 4 files changed, 69 insertions(+), 62 deletions(-) diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py index ff378c544..9efd2dc66 100644 --- a/stonesoup/architecture/architecture.py +++ b/stonesoup/architecture/architecture.py @@ -1,6 +1,6 @@ from abc import abstractmethod from ..base import Base, Property -from .node import Node, SensorNode, RepeaterNode #, FusionNode +from .node import Node, SensorNode, RepeaterNode, FusionNode from .edge import Edge, Edges, DataPiece from ..types.groundtruth import GroundTruthPath from ..types.detection import TrueDetection, Detection, Clutter @@ -174,7 +174,7 @@ def fusion_nodes(self): return processing def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, - bgcolour="lightgray", node_style="filled"): + bgcolour="lightgray", node_style="filled", show_plot=True): """Creates a pdf plot of the directed graph and displays it :param dir_path: The path to save the pdf and .gv files to @@ -280,7 +280,8 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, if not filename: filename = self.name viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') - viz_graph.view() + if show_plot: + viz_graph.view() @property def density(self): diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 2cc69ebda..319a2ab0c 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -27,6 +27,12 @@ def get_message(self): def set_message(self, value): self.put(value) + def __iter__(self): + return self + + def __next__(self): + return self.get_message() + class DataPiece(Base): """A piece of data for use in an architecture. Sent via Messages, and stored in a Node's data_held""" diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 323bfddd8..4445e4c6a 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -9,46 +9,8 @@ from ...sensor.categorical import HMMSensor from ...models.measurement.categorical import MarkovianMeasurementModel -# @pytest.fixture -# def dependencies(): -# E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) -# [0.19, 0.3], # P(medium | bike), P(medium | car) -# [0.01, 0.6]]) # P(large | bike), P(large | car) -# -# model = MarkovianMeasurementModel(emission_matrix=E, -# measurement_categories=['small', 'medium', 'large']) -# -# hmm_sensor = HMMSensor(measurement_model=model) -# -# -# node1 = SensorNode(sensor=hmm_sensor, label='1') -# node2 = SensorNode(sensor=hmm_sensor, label='2') -# node3 = SensorNode(sensor=hmm_sensor, label='3') -# node4 = SensorNode(sensor=hmm_sensor, label='4') -# node5 = SensorNode(sensor=hmm_sensor, label='5') -# node6 = SensorNode(sensor=hmm_sensor, label='6') -# node7 = SensorNode(sensor=hmm_sensor, label='7') -# -# nodes = [node1, node2, node3, node4, node5, node6, node7] -# -# edges = Edges( -# [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), -# Edge((node6, node3)), Edge((node7, node6))]) -# -# fixtures = dict() -# fixtures["Edges"] = edges -# fixtures["Nodes"] = nodes -# -# return fixtures - - -def test_hierarchical_plot(): - #fixtures = fixtures() - - # - # edges = fixtures["Edges"] - # nodes = fixtures["Nodes"] - +@pytest.fixture +def fixtures(): E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) [0.19, 0.3], # P(medium | bike), P(medium | car) [0.01, 0.6]]) # P(large | bike), P(large | car) @@ -68,22 +30,59 @@ def test_hierarchical_plot(): nodes = [node1, node2, node3, node4, node5, node6, node7] - edges = Edges( - [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), - Edge((node6, node3)), Edge((node7, node6))]) + hierarchical_edges = Edges( + [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), + Edge((node6, node3)), Edge((node7, node6))]) + + non_hierarchical_edges = Edges( + [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), + Edge((node6, node3)), Edge((node7, node6)), Edge((node7, node1))]) + + edges2 = Edges([Edge((node2, node1)), Edge((node3, node1))]) + + fixtures = dict() + fixtures["hierarchical_edges"] = hierarchical_edges + fixtures["non_hierarchical_edges"] = non_hierarchical_edges + fixtures["Nodes"] = nodes + fixtures["Edges2"] = edges2 + + return fixtures + + +def test_hierarchical_plot(tmpdir, fixtures): + + nodes = fixtures["Nodes"] + edges = fixtures["hierarchical_edges"] arch = InformationArchitecture(edges=edges) - arch.plot() + arch.plot(dir_path=tmpdir.join('test.pdf'), show_plot=False) + + assert nodes[0].position[1] == 0 + assert nodes[1].position[1] == -1 + assert nodes[2].position[1] == -1 + assert nodes[3].position[1] == -2 + assert nodes[4].position[1] == -2 + assert nodes[5].position[1] == -2 + assert nodes[6].position[1] == -3 + + +def test_density(fixtures): + edges = fixtures["Edges2"] + arch = InformationArchitecture(edges=edges) + + assert arch.density == 2/3 + + +def test_is_hierarchical(fixtures): + + h_edges = fixtures["hierarchical_edges"] + n_h_edges = fixtures["non_hierarchical_edges"] + + h_architecture = InformationArchitecture(edges=h_edges) + n_h_architecture = InformationArchitecture(edges=n_h_edges) - assert nodes[1].position == (0, 0) - assert nodes[2].position == (-0.5, -1) - assert nodes[3].position == (1.0, -1) - assert nodes[4].position == (0.0, -2) - assert nodes[5].position == (-1.0, -2) - assert nodes[6].position == (1.0, -2) - assert nodes[7].position == (1.0, -3) + assert h_architecture.is_hierarchical + assert n_h_architecture.is_hierarchical is False -def test_simple_information_architecture(): - arch = InformationArchitecture() diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index 4a15c304a..1e8bf10ff 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -1,5 +1,6 @@ import datetime +from stonesoup.architecture.edge import FusionQueue from .base import Tracker from ..base import Property from stonesoup.buffered_generator import BufferedGenerator @@ -12,7 +13,7 @@ from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder -def FusionTracker(Tracker): +class FusionTracker(Tracker): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tracks = set() @@ -37,11 +38,11 @@ class SimpleFusionTracker(FusionTracker): # implement tracks method Acts as a wrapper around a base tracker. Track is fixed after the sliding window. It exists within it, but the States may change. """ base_tracker: Tracker = Property(doc="Tracker given to the fusion node") - sliding_window = Property(default=30, - doc="The number of time steps before the result is fixed") - queue = Property(default=None, doc="Queue which feeds in data") - track_fusion_tracker = Property(doc="Tracker for fusing of multiple tracks together") - + sliding_window: int = Property(default=30, + doc="The number of time steps before the result is fixed") + queue: FusionQueue = Property(default=None, + doc="Queue which feeds in data") + track_fusion_tracker: Tracker = Property(doc="Tracker for fusing of multiple tracks together") @property def tracks(self): From 6e8eaecc53e87f5ff6d0da83faeda003aa702c54 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 22 Aug 2023 10:43:36 +0100 Subject: [PATCH 050/170] Delete stonesoup.architecture directory file after replacement with python package alternatice --- stonesoup/architecture/architecture.py | 11 +- stonesoup/architecture2/architecture.py | 483 ------------------ stonesoup/architecture2/edge.py | 194 ------- stonesoup/architecture2/node.py | 171 ------- stonesoup/architecture2/tests/__init__.py | 0 .../architecture2/tests/test_architecture.py | 61 --- 6 files changed, 5 insertions(+), 915 deletions(-) delete mode 100644 stonesoup/architecture2/architecture.py delete mode 100644 stonesoup/architecture2/edge.py delete mode 100644 stonesoup/architecture2/node.py delete mode 100644 stonesoup/architecture2/tests/__init__.py delete mode 100644 stonesoup/architecture2/tests/test_architecture.py diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py index 9efd2dc66..36e46b048 100644 --- a/stonesoup/architecture/architecture.py +++ b/stonesoup/architecture/architecture.py @@ -1,9 +1,9 @@ from abc import abstractmethod from ..base import Base, Property from .node import Node, SensorNode, RepeaterNode, FusionNode -from .edge import Edge, Edges, DataPiece +from .edge import Edges, DataPiece from ..types.groundtruth import GroundTruthPath -from ..types.detection import TrueDetection, Detection, Clutter +from ..types.detection import TrueDetection, Clutter from typing import List, Collection, Tuple, Set, Union, Dict import numpy as np @@ -13,6 +13,7 @@ from datetime import datetime, timedelta import threading + class Architecture(Base): edges: Edges = Property( doc="An Edges object containing all edges. For A to be connected to B we would have an " @@ -20,8 +21,8 @@ class Architecture(Base): current_time: datetime = Property( default=datetime.now(), doc="The time which the instance is at for the purpose of simulation. " - "This is increased by the propagate method. This should be set to the earliest timestep" - " from the ground truth") + "This is increased by the propagate method. This should be set to the earliest " + "timestep from the ground truth") name: str = Property( default=None, doc="A name for the architecture, to be used to name files and/or title plots. Default is " @@ -315,8 +316,6 @@ def is_hierarchical(self): @property def is_centralised(self): n_parents = 0 - one_parent = 0 - multiple_parents = 0 for node in self.all_nodes: if len(node.children) == 0: n_parents += 1 diff --git a/stonesoup/architecture2/architecture.py b/stonesoup/architecture2/architecture.py deleted file mode 100644 index 8e6cab26c..000000000 --- a/stonesoup/architecture2/architecture.py +++ /dev/null @@ -1,483 +0,0 @@ -from abc import abstractmethod -from ..base import Base, Property -from .node import Node, FusionNode, SensorNode, RepeaterNode -from .edge import Edge, Edges, DataPiece -from ..types.groundtruth import GroundTruthPath -from ..types.detection import TrueDetection, Detection, Clutter - -from typing import List, Collection, Tuple, Set, Union, Dict -import numpy as np -import networkx as nx -import graphviz -from string import ascii_uppercase as auc -from datetime import datetime, timedelta -import threading - -class Architecture(Base): - edges: Edges = Property( - doc="An Edges object containing all edges. For A to be connected to B we would have an " - "Edge with edge_pair=(A, B) in this object.") - current_time: datetime = Property( - default=datetime.now(), - doc="The time which the instance is at for the purpose of simulation. " - "This is increased by the propagate method. This should be set to the earliest timestep" - " from the ground truth") - name: str = Property( - default=None, - doc="A name for the architecture, to be used to name files and/or title plots. Default is " - "the class name") - force_connected: bool = Property( - default=True, - doc="If True, the undirected version of the graph must be connected, ie. all nodes should " - "be connected via some path. Set this to False to allow an unconnected architecture. " - "Default is True") - - # Below is no longer required with changes to plot - didn't delete in case we want to revert - # to previous method - # font_size: int = Property( - # default=8, - # doc='Font size for node labels') - # node_dim: tuple = Property( - # default=(0.5, 0.5), - # doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.name: - self.name = type(self).__name__ - if not self.current_time: - self.current_time = datetime.now() - - self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) - - if self.force_connected and not self.is_connected and len(self) > 0: - raise ValueError("The graph is not connected. Use force_connected=False, " - "if you wish to override this requirement") - - # Set attributes such as label, colour, shape, etc for each node - last_letters = {'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', - 'RepeaterNode': ''} - for node in self.di_graph.nodes: - if node.label: - label = node.label - else: - label, last_letters = _default_label(node, last_letters) - node.label = label - attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", - "fontsize": f"{node.font_size}", "width": f"{node.node_dim[0]}", - "height": f"{node.node_dim[1]}", "fixedsize": True} - self.di_graph.nodes[node].update(attr) - - def parents(self, node: Node): - """Returns a set of all nodes to which the input node has a direct edge to""" - if node not in self.all_nodes: - raise ValueError("Node not in this architecture") - parents = set() - for other in self.all_nodes: - if (node, other) in self.edges.edge_list: - parents.add(other) - return parents - - def children(self, node: Node): - """Returns a set of all nodes to which the input node has a direct edge from""" - if node not in self.all_nodes: - raise ValueError("Node not in this architecture") - children = set() - for other in self.all_nodes: - if (other, node) in self.edges.edge_list: - children.add(other) - return children - - def sibling_group(self, node: Node): - """Returns a set of siblings of the given node. The given node is included in this set.""" - siblings = set() - for parent in self.parents(node): - for child in self.children(parent): - siblings.add(child) - return siblings - - @property - def shortest_path_dict(self): - g = nx.DiGraph() - for edge in self.edges.edge_list: - g.add_edge(edge[0], edge[1]) - path = nx.all_pairs_shortest_path_length(g) - dpath = {x[0]: x[1] for x in path} - return dpath - - def _parent_position(self, node: Node): - """Returns a tuple of (x_coord, y_coord) giving the location of a node's parent""" - parents = self.parents(node) - if len(parents) == 1: - parent = parents.pop() - else: - raise ValueError("Node has more than one parent") - return parent.position - - @property - def top_nodes(self): - top_nodes = list() - for node in self.all_nodes: - if len(self.parents(node)) == 0: - # This node must be the top level node - top_nodes.append(node) - - return top_nodes - - def number_of_leaves(self, node: Node): - node_leaves = set() - non_leaves = 0 - for leaf_node in self.leaf_nodes: - try: - shortest_path = self.shortest_path_dict[leaf_node][node] - if shortest_path != 0: - node_leaves.add(leaf_node) - except KeyError: - non_leaves += 1 - - if len(node_leaves) == 0: - return 1 - else: - return len(node_leaves) - - @property - def leaf_nodes(self): - leaf_nodes = set() - for node in self.all_nodes: - if len(self.children(node)) == 0: - # This must be a leaf node - leaf_nodes.add(node) - return leaf_nodes - - @abstractmethod - def propagate(self, time_increment: float): - raise NotImplementedError - - @property - def all_nodes(self): - return set(self.di_graph.nodes) - - @property - def sensor_nodes(self): - sensor_nodes = set() - for node in self.all_nodes: - if isinstance(node, SensorNode): - sensor_nodes.add(node) - return sensor_nodes - - @property - def fusion_nodes(self): - processing = set() - for node in self.all_nodes: - if isinstance(node, FusionNode): - processing.add(node) - return processing - - def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, - bgcolour="lightgray", node_style="filled"): - """Creates a pdf plot of the directed graph and displays it - - :param dir_path: The path to save the pdf and .gv files to - :param filename: Name to call the associated files - :param use_positions: - :param plot_title: If a string is supplied, makes this the title of the plot. If True, uses - the name attribute of the graph to title the plot. If False, no title is used. - Default is False - :param bgcolour: String containing the background colour for the plot. - Default is "lightgray". See graphviz attributes for more information. - One alternative is "white" - :param node_style: String containing the node style for the plot. - Default is "filled". See graphviz attributes for more information. - One alternative is "solid" - :return: - """ - if use_positions: - for node in self.di_graph.nodes: - if not isinstance(node.position, Tuple): - raise TypeError("If use_positions is set to True, every node must have a " - "position, given as a Tuple of length 2") - attr = {"pos": f"{node.position[0]},{node.position[1]}!"} - self.di_graph.nodes[node].update(attr) - elif self.is_hierarchical: - - # Find top node and assign location - top_nodes = self.top_nodes - if len(top_nodes) == 1: - top_node = top_nodes[0] - else: - raise ValueError("Graph with more than one top level node provided.") - - top_node.position = (0, 0) - attr = {"pos": f"{top_node.position[0]},{top_node.position[1]}!"} - self.di_graph.nodes[top_node].update(attr) - - # Set of nodes that have been plotted / had their positions updated - plotted_nodes = set() - plotted_nodes.add(top_node) - - # Set of nodes that have been plotted, but need to have parent nodes plotted - layer_nodes = set() - layer_nodes.add(top_node) - - # Initialise a layer count - layer = -1 - while len(plotted_nodes) < len(self.all_nodes): - - # Initialse an empty set to store nodes to be considered in the next iteration - next_layer_nodes = set() - - # Iterate through nodes on the current layer (nodes that have been plotted but have - # child nodes that are not plotted) - for layer_node in layer_nodes: - - # Find children of the parent node - children = self.children(layer_node) - - # Find number of leaf nodes that descend from the parent - n_parent_leaves = self.number_of_leaves(layer_node) - - # Get parent x_loc - parent_x_loc = layer_node.position[0] - - # Get location of left limit of the range that leaf nodes will be plotted in - l_x_loc = parent_x_loc - n_parent_leaves/2 - left_limit = l_x_loc - - for child in children: - # Calculate x_loc of the child node - x_loc = left_limit + self.number_of_leaves(child)/2 - - # Update the left limit - left_limit += self.number_of_leaves(child) - - # Update the position of the child node - child.position = (x_loc, layer) - attr = {"pos": f"{child.position[0]},{child.position[1]}!"} - self.di_graph.nodes[child].update(attr) - - # Add child node to list of nodes to be considered in next iteration, and - # to list of nodes that have been plotted - next_layer_nodes.add(child) - plotted_nodes.add(child) - - # Set list of nodes to be considered next iteration - layer_nodes = next_layer_nodes - - # Update layer count for correct y location - layer -= 1 - - dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() - dot_split = dot.split('\n') - dot_split.insert(1, f"graph [bgcolor={bgcolour}]") - dot_split.insert(1, f"node [style={node_style}]") - dot = "\n".join(dot_split) - if plot_title: - if plot_title is True: - plot_title = self.name - elif not isinstance(plot_title, str): - raise ValueError("Plot title must be a string, or True") - dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{plot_title}\";" + "}" - if not filename: - filename = self.name - viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') - viz_graph.view() - - @property - def density(self): - """Returns the density of the graph, ie. the proportion of possible edges between nodes - that exist in the graph""" - num_nodes = len(self.all_nodes) - num_edges = len(self.edges) - architecture_density = num_edges / ((num_nodes * (num_nodes - 1)) / 2) - return architecture_density - - @property - def is_hierarchical(self): - """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" - # if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: - no_parents = 0 - one_parent = 0 - multiple_parents = 0 - for node in self.all_nodes: - if len(self.parents(node)) == 0: - no_parents += 1 - elif len(self.parents(node)) == 1: - one_parent += 1 - elif len(self.parents(node)) > 1: - multiple_parents += 1 - - if multiple_parents == 0 and no_parents == 1: - return True - else: - return False - - @property - def is_centralised(self): - n_parents = 0 - one_parent = 0 - multiple_parents = 0 - for node in self.all_nodes: - if len(node.children) == 0: - n_parents += 1 - - if n_parents == 0: - return True - else: - return False - - @property - def is_connected(self): - return nx.is_connected(self.to_undirected) - - @property - def to_undirected(self): - return self.di_graph.to_undirected() - - def __len__(self): - return len(self.di_graph) - - @property - def fully_propagated(self): - """Checks if all data for each node have been transferred - to its parents. With zero latency, this should be the case after running propagate""" - for edge in self.edges.edges: - if len(edge.unsent_data) != 0: - return False - - return True - - -class InformationArchitecture(Architecture): - """The architecture for how information is shared through the network. Node A is " - "connected to Node B if and only if the information A creates by processing and/or " - "sensing is received and opened by B without modification by another node. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for node in self.all_nodes: - if isinstance(node, RepeaterNode): - raise TypeError("Information architecture should not contain any repeater nodes") - for fusion_node in self.fusion_nodes: - fusion_node.tracker.set_time(self.current_time) - - def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, - **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: - """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ - all_detections = dict() - - # Get rid of ground truths that have not yet happened - # (ie GroundTruthState's with timestamp after self.current_time) - new_ground_truths = set() - for ground_truth_path in ground_truths: - # need an if len(states) == 0 continue condition here? - new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) - - for sensor_node in self.sensor_nodes: - all_detections[sensor_node] = set() - for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): - all_detections[sensor_node].add(detection) - - # Borrowed below from SensorSuite. I don't think it's necessary, but might be something - # we need. If so, will need to define self.attributes_inform - - # attributes_dict = \ - # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) - # for attribute_name in self.attributes_inform} - # - # for detection in all_detections[sensor_node]: - # detection.metadata.update(attributes_dict) - - for data in all_detections[sensor_node]: - # The sensor acquires its own data instantly - sensor_node.update(data.timestamp, data.timestamp, - DataPiece(sensor_node, sensor_node, data, data.timestamp), - 'created') - - return all_detections - - def propagate(self, time_increment: float, failed_edges: Collection = None): - """Performs the propagation of the measurements through the network""" - for edge in self.edges.edges: - if failed_edges and edge in failed_edges: - edge._failed(self.current_time, time_increment) - continue # No data passed along these edges - edge.update_messages(self.current_time) - # fuse goes here? - for data_piece, time_pertaining in edge.unsent_data: - edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) - - # for node in self.processing_nodes: - # node.process() # This should happen when a new message is received - count = 0 - if not self.fully_propagated: - count += 1 - self.propagate(time_increment, failed_edges) - return - - for fuse_node in self.fusion_nodes: - x = threading.Thread(target=fuse_node.fuse) - x.start() - - self.current_time += timedelta(seconds=time_increment) - for fusion_node in self.fusion_nodes: - fusion_node.tracker.set_time(self.current_time) - - -class NetworkArchitecture(Architecture): - """The architecture for how data is propagated through the network. Node A is connected " - "to Node B if and only if A sends its data through B. """ - - def propagate(self, time_increment: float): - # Still have to deal with latency/bandwidth - self.current_time += timedelta(seconds=time_increment) - for node in self.all_nodes: - for parent in self.parents(node): - for data in node.data_held: - parent.update(self.current_time, data) - - -class CombinedArchitecture(Base): - """Contains an information and a network architecture that pertain to the same scenario. """ - information_architecture: InformationArchitecture = Property( - doc="The information architecture for how information is shared. ") - network_architecture: NetworkArchitecture = Property( - doc="The architecture for how data is propagated through the network. Node A is connected " - "to Node B if and only if A sends its data through B. ") - - def propagate(self, time_increment: float): - # First we simulate the network - self.network_architecture.propagate(time_increment) - # Now we want to only pass information along in the information architecture if it - # Was in the information architecture by at least one path. - # Some magic here - failed_edges = [] # return this from n_arch.propagate? - self.information_architecture.propagate(time_increment, failed_edges) - - -def _default_label(node, last_letters): - """Utility function to generate default labels for nodes, where none are given - Takes a node, and a dictionary with the letters last used for each class, - ie `last_letters['Node']` might return 'AA', meaning the last Node was labelled 'Node AA'""" - node_type = type(node).__name__ - type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' - new_letters = _default_letters(type_letters) - last_letters[node_type] = new_letters - return node_type + ' ' + new_letters, last_letters - - -def _default_letters(type_letters) -> str: - if type_letters == '': - return 'A' - count = 0 - letters_list = [*type_letters] - # Move through string from right to left and shift any Z's up to A's - while letters_list[-1 - count] == 'Z': - letters_list[-1 - count] = 'A' - count += 1 - if count == len(letters_list): - return 'A' * (count + 1) - # Shift current letter up by one - current_letter = letters_list[-1 - count] - letters_list[-1 - count] = auc[auc.index(current_letter) + 1] - new_letters = ''.join(letters_list) - return new_letters diff --git a/stonesoup/architecture2/edge.py b/stonesoup/architecture2/edge.py deleted file mode 100644 index 4f047d4fb..000000000 --- a/stonesoup/architecture2/edge.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -from ..base import Base, Property -from ..types.time import TimeRange -from ..types.track import Track -from ..types.detection import Detection -from ..types.hypothesis import Hypothesis -from node import _dict_set - -from typing import Union, Tuple, List, TYPE_CHECKING -from datetime import datetime, timedelta -from queue import Queue - -if TYPE_CHECKING: - from .node import Node - - -class FusionQueue(Queue): - """A queue from which fusion nodes draw data they have yet to fuse""" - def __init__(self): - super().__init__(maxsize=999999) - - def get_message(self): - value = self.get() - return value - - def set_message(self, value): - self.put(value) - - -class DataPiece(Base): - """A piece of data for use in an architecture. Sent via Messages, and stored in a Node's data_held""" - node: "Node" = Property( - doc="The Node this data piece belongs to") - originator: "Node" = Property( - doc="The node which first created this data, ie by sensing or fusing information together. " - "If the data is simply passed along the chain, the originator remains unchanged. ") - data: Union[Detection, Track, Hypothesis] = Property( - doc="A Detection, Track, or Hypothesis") - time_arrived: datetime = Property( - doc="The time at which this piece of data was received by the Node, either by Message or by sensing.") - track: Track = Property( - doc="The Track in the event of data being a Hypothesis", - default=None) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.sent_to = set() # all Nodes the data_piece has been sent to, to avoid duplicates - - -class Edge(Base): - """Comprised of two connected Nodes""" - nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (child, parent") - edge_latency: float = Property(doc="The latency stemming from the edge itself, " - "and not either of the nodes", - default=0) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.messages_held = {"pending": {}, # For pending, messages indexed by time sent. - "received": {}} # For received, by time received - self.time_ranges_failed = [] # List of time ranges during which this edge was failed - - def send_message(self, data_piece, time_pertaining, time_sent): - if not isinstance(data_piece, DataPiece): - raise TypeError("Message info must be one of the following types: " - "Detection, Hypothesis or Track") - # Add message to 'pending' dict of edge - message = Message(self, time_pertaining, time_sent, data_piece) - _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) - # ensure message not re-sent - data_piece.sent_to.add(self.nodes[1]) - - def update_messages(self, current_time): - # Check info type is what we expect - to_remove = set() # Needed as we can't change size of a set during iteration - for time in self.messages_held['pending']: - for message in self.messages_held['pending'][time]: - message.update(current_time) - if message.status == 'received': - # Then the latency has passed and message has been received - # Move message from pending to received messages in edge - to_remove.add((time, message)) - _, self.messages_held = _dict_set(self.messages_held, message, - 'received', message.arrival_time) - # Update - message.recipient_node.update(message.time_pertaining, message.arrival_time, - message.data_piece, "unfused") - - for time, message in to_remove: - self.messages_held['pending'][time].remove(message) - - def failed(self, current_time, duration): - """Keeps track of when this edge was failed using the time_ranges_failed property. """ - end_time = current_time + timedelta(duration) - self.time_ranges_failed.append(TimeRange(current_time, end_time)) - - @property - def child(self): - return self.nodes[0] - - @property - def parent(self): - return self.nodes[1] - - @property - def ovr_latency(self): - """Overall latency including the two Nodes and the edge latency.""" - return self.child.latency + self.edge_latency + self.parent.latency - - @property - def unsent_data(self): - """Data held by the child that has not been sent to the parent.""" - unsent = [] - for status in ["fused", "created"]: - for time_pertaining in self.child.data_held[status]: - for data_piece in self.child.data_held[status][time_pertaining]: - if self.parent not in data_piece.sent_to: - unsent.append((data_piece, time_pertaining)) - return unsent - - -class Edges(Base): - """Container class for Edge""" - edges: List[Edge] = Property(doc="List of Edge objects", default=None) - - def add(self, edge): - self.edges.append(edge) - - def get(self, node_pair): - if not isinstance(node_pair, Tuple) and all(isinstance(node, Node) for node in node_pair): - raise TypeError("Must supply a tuple of nodes") - if not len(node_pair) == 2: - raise ValueError("Incorrect tuple length. Must be of length 2") - for edge in self.edges: - if edge.nodes == node_pair: - # Assume this is the only match? - return edge - return None - - @property - def edge_list(self): - """Returns a list of tuples in the form (child, parent)""" - edge_list = [] - for edge in self.edges: - edge_list.append(edge.nodes) - return edge_list - - def __len__(self): - return len(self.edges) - - -class Message(Base): - """A message, containing a piece of information, that gets propagated between two Nodes. - Messages are opened by nodes that are a parent of the node that sent the message""" - edge: Edge = Property( - doc="The directed edge containing the sender and receiver of the message") - time_pertaining: datetime = Property( - doc="The latest time for which the data pertains. For a Detection, this would be the time " - "of the Detection, or for a Track this is the time of the last State in the Track. " - "Different from time_sent when data is passed on that was not generated by this " - "Node's child") - time_sent: datetime = Property( - doc="Time at which the message was sent") - data_piece: DataPiece = Property( - doc="Info that the sent message contains") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.status = "sending" - - @property - def generator_node(self): - return self.edge.child - - @property - def recipient_node(self): - return self.edge.parent - - @property - def arrival_time(self): - # TODO: incorporate failed time ranges here. Not essential for a first PR. Could do with merging of PR #664 - return self.time_sent + timedelta(seconds=self.edge.ovr_latency) - - def update(self, current_time): - progress = (current_time - self.time_sent).total_seconds() - if progress < self.edge.child.latency: - self.status = "sending" - elif progress < self.edge.child.latency + self.edge.edge_latency: - self.status = "transferring" - elif progress < self.edge.ovr_latency: - self.status = "receiving" - else: - self.status = "received" diff --git a/stonesoup/architecture2/node.py b/stonesoup/architecture2/node.py deleted file mode 100644 index 6344833e8..000000000 --- a/stonesoup/architecture2/node.py +++ /dev/null @@ -1,171 +0,0 @@ -from ..base import Property, Base -from ..sensor.sensor import Sensor -from ..types.detection import Detection -from ..types.hypothesis import Hypothesis -from ..types.track import Track -from ..tracker.base import Tracker -from .edge import DataPiece, FusionQueue -from ..tracker.fusion import FusionTracker -from datetime import datetime -from typing import Tuple - - -class Node(Base): - """Base node class. Should be abstract""" - latency: float = Property( - doc="Contribution to edge latency stemming from this node", - default=0) - label: str = Property( - doc="Label to be displayed on graph", - default=None) - position: Tuple[float] = Property( - default=None, - doc="Cartesian coordinates for node") - colour: str = Property( - default=None, - doc='Colour to be displayed on graph') - shape: str = Property( - default=None, - doc='Shape used to display nodes') - font_size: int = Property( - default=None, - doc='Font size for node labels') - node_dim: tuple = Property( - default=None, - doc='Width and height of nodes for graph icons, default is (0.5, 0.5)') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.data_held = {"fused": {}, "created": {}, "unfused": {}} - - def update(self, time_pertaining, time_arrived, data_piece, category, track=None): - if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): - raise TypeError("Times must be datetime objects") - if not track: - if not isinstance(data_piece.data, Detection) and not isinstance(data_piece.data, Track): - raise TypeError(f"Data provided without accompanying Track must be a Detection or a Track, not a " - f"{type(data_piece.data).__name__}") - new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, time_arrived) - else: - if not isinstance(data_piece.data, Hypothesis): - raise TypeError("Data provided with Track must be a Hypothesis") - new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, time_arrived, track) - - added, self.data_held[category] = _dict_set(self.data_held[category], new_data_piece, time_pertaining) - if isinstance(self, FusionNode): - self.fusion_queue.set_message(new_data_piece) - - return added - - -class SensorNode(Node): - """A node corresponding to a Sensor. Fresh data is created here""" - sensor: Sensor = Property(doc="Sensor corresponding to this node") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#1f77b4' - if not self.shape: - self.shape = 'oval' - if not self.font_size: - self.font_size = 5 - if not self.node_dim: - self.node_dim = (0.5, 0.3) - - -class FusionNode(Node): - """A node that does not measure new data, but does process data it receives""" - # feeder probably as well - tracker: FusionTracker = Property( - doc="Tracker used by this Node to fuse together Tracks and Detections") - fusion_queue: FusionQueue = Property( - default=FusionQueue(), - doc="The queue from which this node draws data to be fused") - tracks: set = Property(default=None, - doc="Set of tracks tracked by the fusion node") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#006400' - if not self.shape: - self.shape = 'hexagon' - if not self.font_size: - self.font_size = 5 - if not self.node_dim: - self.node_dim = (0.6, 0.3) - self.tracks = set() # Set of tracks this Node has recorded - - def fuse(self): - print("A node be fusin") - # we have a queue. - data = self.fusion_queue.get_message() - - # Sort detections and tracks and group by time - - if data: - # track it - print("there's data") - for time, track in self.tracker: - self.tracks.update(track) - else: - print("no data") - return - - -class SensorFusionNode(SensorNode, FusionNode): - """A node that is both a sensor and also processes data""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.colour in ['#006400', '#1f77b4']: - self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating - if self.shape in ['oval', 'hexagon']: - self.shape = 'rectangle' - if not self.font_size: - self.font_size = 5 - if not self.node_dim: - self.node_dim = (0.1, 0.3) - - -class RepeaterNode(Node): - """A node which simply passes data along to others, without manipulating the data itself. """ - # Latency property could go here - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#ff7f0e' - if not self.shape: - self.shape = 'circle' - if not self.font_size: - self.font_size = 5 - if not self.node_dim: - self.node_dim = (0.5, 0.3) - - -def _dict_set(my_dict, value, key1, key2=None): - """Utility function to add value to my_dict at the specified key(s) - Returns True iff the set increased in size, ie the value was new to its position""" - if not my_dict: - if key2: - my_dict = {key1: {key2: {value}}} - else: - my_dict = {key1: {value}} - elif key2: - if key1 in my_dict: - if key2 in my_dict[key1]: - old_len = len(my_dict[key1][key2]) - my_dict[key1][key2].add(value) - return len(my_dict[key1][key2]) == old_len + 1, my_dict - else: - my_dict[key1][key2] = {value} - else: - my_dict[key1] = {key2: {value}} - else: - if key1 in my_dict: - old_len = len(my_dict[key1]) - my_dict[key1].add(value) - return len(my_dict[key1]) == old_len + 1, my_dict - else: - my_dict[key1] = {value} - return True, my_dict diff --git a/stonesoup/architecture2/tests/__init__.py b/stonesoup/architecture2/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/stonesoup/architecture2/tests/test_architecture.py b/stonesoup/architecture2/tests/test_architecture.py deleted file mode 100644 index 671f2164f..000000000 --- a/stonesoup/architecture2/tests/test_architecture.py +++ /dev/null @@ -1,61 +0,0 @@ -import pytest - -import numpy as np - - -from ..architecture import InformationArchitecture -from ..edge import Edge, Edges -from ..node import RepeaterNode, SensorNode -from ...sensor.categorical import HMMSensor -from ...models.measurement.categorical import MarkovianMeasurementModel - -@pytest.fixture -def dependencies(): - E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) - [0.19, 0.3], # P(medium | bike), P(medium | car) - [0.01, 0.6]]) # P(large | bike), P(large | car) - - model = MarkovianMeasurementModel(emission_matrix=E, - measurement_categories=['small', 'medium', 'large']) - - hmm_sensor = HMMSensor(measurement_model=model) - - - node1 = SensorNode(sensor=hmm_sensor, label='1') - node2 = SensorNode(sensor=hmm_sensor, label='2') - node3 = SensorNode(sensor=hmm_sensor, label='3') - node4 = SensorNode(sensor=hmm_sensor, label='4') - node5 = SensorNode(sensor=hmm_sensor, label='5') - node6 = SensorNode(sensor=hmm_sensor, label='6') - node7 = SensorNode(sensor=hmm_sensor, label='7') - - nodes = [node1, node2, node3, node4, node5, node6, node7] - - edges = Edges( - [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), - Edge((node6, node3)), Edge((node7, node6))]) - - fixtures = dict() - fixtures["Edges"] = edges - fixtures["Nodes"] = nodes - - return fixtures - - -def test_hierarchical_plot(fixtures): - fixtures = fixtures() - edges = fixtures["Edges"] - nodes = fixtures["Nodes"] - - arch = InformationArchitecture(edges=edges) - - arch.plot() - - assert nodes[1].position == (0, 0) - assert nodes[2].position == (-0.5, -1) - assert nodes[3].position == (1.0, -1) - assert nodes[4].position == (0.0, -2) - assert nodes[5].position == (-1.0, -2) - assert nodes[6].position == (1.0, -2) - assert nodes[7].position == (1.0, -3) - From 033426f50bcb9e4047b57b5a5743a853c9e189df Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Tue, 22 Aug 2023 16:42:40 +0100 Subject: [PATCH 051/170] neatening of architectures --- stonesoup/architecture/__init__.py | 454 +++++++++++ stonesoup/architecture/architecture.py | 483 ------------ stonesoup/architecture/edge.py | 31 +- stonesoup/architecture/functions.py | 32 + stonesoup/architecture/node.py | 80 +- .../architecture/tests/test_architecture.py | 2 +- stonesoup/tracker/fusion.py | 5 +- stonesoup/types/architecture.py | 711 ------------------ stonesoup/types/state.py | 2 +- stonesoup/types/tests/test_architecture.py | 374 --------- 10 files changed, 547 insertions(+), 1627 deletions(-) delete mode 100644 stonesoup/architecture/architecture.py delete mode 100644 stonesoup/types/architecture.py delete mode 100644 stonesoup/types/tests/test_architecture.py diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index e69de29bb..1a9fca0ad 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -0,0 +1,454 @@ +from abc import abstractmethod +from ..base import Base, Property +from .node import Node, SensorNode, RepeaterNode, FusionNode +from .edge import Edges, DataPiece +from ..types.groundtruth import GroundTruthPath +from ..types.detection import TrueDetection, Clutter +from .functions import _default_letters, _default_label + +from typing import List, Collection, Tuple, Set, Union, Dict +import numpy as np +import networkx as nx +import graphviz +from datetime import datetime, timedelta +import threading + + +class Architecture(Base): + edges: Edges = Property( + doc="An Edges object containing all edges. For A to be connected to B we would have an " + "Edge with edge_pair=(A, B) in this object.") + current_time: datetime = Property( + default=datetime.now(), + doc="The time which the instance is at for the purpose of simulation. " + "This is increased by the propagate method. This should be set to the earliest " + "timestep from the ground truth") + name: str = Property( + default=None, + doc="A name for the architecture, to be used to name files and/or title plots. Default is " + "the class name") + force_connected: bool = Property( + default=True, + doc="If True, the undirected version of the graph must be connected, ie. all nodes should " + "be connected via some path. Set this to False to allow an unconnected architecture. " + "Default is True") + + # Below is no longer required with changes to plot - didn't delete in case we want to revert + # to previous method + # font_size: int = Property( + # default=8, + # doc='Font size for node labels') + # node_dim: tuple = Property( + # default=(0.5, 0.5), + # doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.name: + self.name = type(self).__name__ + if not self.current_time: + self.current_time = datetime.now() + + self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) + + if self.force_connected and not self.is_connected and len(self) > 0: + raise ValueError("The graph is not connected. Use force_connected=False, " + "if you wish to override this requirement") + + # Set attributes such as label, colour, shape, etc for each node + last_letters = {'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', + 'RepeaterNode': ''} + for node in self.di_graph.nodes: + if node.label: + label = node.label + else: + label, last_letters = _default_label(node, last_letters) + node.label = label + attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", + "fontsize": f"{node.font_size}", "width": f"{node.node_dim[0]}", + "height": f"{node.node_dim[1]}", "fixedsize": True} + self.di_graph.nodes[node].update(attr) + + def parents(self, node: Node): + """Returns a set of all nodes to which the input node has a direct edge to""" + if node not in self.all_nodes: + raise ValueError("Node not in this architecture") + parents = set() + for other in self.all_nodes: + if (node, other) in self.edges.edge_list: + parents.add(other) + return parents + + def children(self, node: Node): + """Returns a set of all nodes to which the input node has a direct edge from""" + if node not in self.all_nodes: + raise ValueError("Node not in this architecture") + children = set() + for other in self.all_nodes: + if (other, node) in self.edges.edge_list: + children.add(other) + return children + + def sibling_group(self, node: Node): + """Returns a set of siblings of the given node. The given node is included in this set.""" + siblings = set() + for parent in self.parents(node): + for child in self.children(parent): + siblings.add(child) + return siblings + + @property + def shortest_path_dict(self): + g = nx.DiGraph() + for edge in self.edges.edge_list: + g.add_edge(edge[0], edge[1]) + path = nx.all_pairs_shortest_path_length(g) + dpath = {x[0]: x[1] for x in path} + return dpath + + def _parent_position(self, node: Node): + """Returns a tuple of (x_coord, y_coord) giving the location of a node's parent""" + parents = self.parents(node) + if len(parents) == 1: + parent = parents.pop() + else: + raise ValueError("Node has more than one parent") + return parent.position + + @property + def top_nodes(self): + top_nodes = list() + for node in self.all_nodes: + if len(self.parents(node)) == 0: + # This node must be the top level node + top_nodes.append(node) + + return top_nodes + + def number_of_leaves(self, node: Node): + node_leaves = set() + non_leaves = 0 + for leaf_node in self.leaf_nodes: + try: + shortest_path = self.shortest_path_dict[leaf_node][node] + if shortest_path != 0: + node_leaves.add(leaf_node) + except KeyError: + non_leaves += 1 + + if len(node_leaves) == 0: + return 1 + else: + return len(node_leaves) + + @property + def leaf_nodes(self): + leaf_nodes = set() + for node in self.all_nodes: + if len(self.children(node)) == 0: + # This must be a leaf node + leaf_nodes.add(node) + return leaf_nodes + + @abstractmethod + def propagate(self, time_increment: float): + raise NotImplementedError + + @property + def all_nodes(self): + return set(self.di_graph.nodes) + + @property + def sensor_nodes(self): + sensor_nodes = set() + for node in self.all_nodes: + if isinstance(node, SensorNode): + sensor_nodes.add(node) + return sensor_nodes + + @property + def fusion_nodes(self): + processing = set() + for node in self.all_nodes: + if isinstance(node, FusionNode): + processing.add(node) + return processing + + def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, + bgcolour="lightgray", node_style="filled", show_plot=True): + """Creates a pdf plot of the directed graph and displays it + + :param dir_path: The path to save the pdf and .gv files to + :param filename: Name to call the associated files + :param use_positions: + :param plot_title: If a string is supplied, makes this the title of the plot. If True, uses + the name attribute of the graph to title the plot. If False, no title is used. + Default is False + :param bgcolour: String containing the background colour for the plot. + Default is "lightgray". See graphviz attributes for more information. + One alternative is "white" + :param node_style: String containing the node style for the plot. + Default is "filled". See graphviz attributes for more information. + One alternative is "solid" + :return: + """ + if use_positions: + for node in self.di_graph.nodes: + if not isinstance(node.position, Tuple): + raise TypeError("If use_positions is set to True, every node must have a " + "position, given as a Tuple of length 2") + attr = {"pos": f"{node.position[0]},{node.position[1]}!"} + self.di_graph.nodes[node].update(attr) + elif self.is_hierarchical: + + # Find top node and assign location + top_nodes = self.top_nodes + if len(top_nodes) == 1: + top_node = top_nodes[0] + else: + raise ValueError("Graph with more than one top level node provided.") + + top_node.position = (0, 0) + attr = {"pos": f"{top_node.position[0]},{top_node.position[1]}!"} + self.di_graph.nodes[top_node].update(attr) + + # Set of nodes that have been plotted / had their positions updated + plotted_nodes = set() + plotted_nodes.add(top_node) + + # Set of nodes that have been plotted, but need to have parent nodes plotted + layer_nodes = set() + layer_nodes.add(top_node) + + # Initialise a layer count + layer = -1 + while len(plotted_nodes) < len(self.all_nodes): + + # Initialse an empty set to store nodes to be considered in the next iteration + next_layer_nodes = set() + + # Iterate through nodes on the current layer (nodes that have been plotted but have + # child nodes that are not plotted) + for layer_node in layer_nodes: + + # Find children of the parent node + children = self.children(layer_node) + + # Find number of leaf nodes that descend from the parent + n_parent_leaves = self.number_of_leaves(layer_node) + + # Get parent x_loc + parent_x_loc = layer_node.position[0] + + # Get location of left limit of the range that leaf nodes will be plotted in + l_x_loc = parent_x_loc - n_parent_leaves/2 + left_limit = l_x_loc + + for child in children: + # Calculate x_loc of the child node + x_loc = left_limit + self.number_of_leaves(child)/2 + + # Update the left limit + left_limit += self.number_of_leaves(child) + + # Update the position of the child node + child.position = (x_loc, layer) + attr = {"pos": f"{child.position[0]},{child.position[1]}!"} + self.di_graph.nodes[child].update(attr) + + # Add child node to list of nodes to be considered in next iteration, and + # to list of nodes that have been plotted + next_layer_nodes.add(child) + plotted_nodes.add(child) + + # Set list of nodes to be considered next iteration + layer_nodes = next_layer_nodes + + # Update layer count for correct y location + layer -= 1 + + dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() + dot_split = dot.split('\n') + dot_split.insert(1, f"graph [bgcolor={bgcolour}]") + dot_split.insert(1, f"node [style={node_style}]") + dot = "\n".join(dot_split) + if plot_title: + if plot_title is True: + plot_title = self.name + elif not isinstance(plot_title, str): + raise ValueError("Plot title must be a string, or True") + dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{plot_title}\";" + "}" + if not filename: + filename = self.name + viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') + if show_plot: + viz_graph.view() + + @property + def density(self): + """Returns the density of the graph, ie. the proportion of possible edges between nodes + that exist in the graph""" + num_nodes = len(self.all_nodes) + num_edges = len(self.edges) + architecture_density = num_edges / ((num_nodes * (num_nodes - 1)) / 2) + return architecture_density + + @property + def is_hierarchical(self): + """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" + # if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: + no_parents = 0 + one_parent = 0 + multiple_parents = 0 + for node in self.all_nodes: + if len(self.parents(node)) == 0: + no_parents += 1 + elif len(self.parents(node)) == 1: + one_parent += 1 + elif len(self.parents(node)) > 1: + multiple_parents += 1 + + if multiple_parents == 0 and no_parents == 1: + return True + else: + return False + + @property + def is_centralised(self): + n_parents = 0 + for node in self.all_nodes: + if len(node.children) == 0: + n_parents += 1 + + if n_parents == 0: + return True + else: + return False + + @property + def is_connected(self): + return nx.is_connected(self.to_undirected) + + @property + def to_undirected(self): + return self.di_graph.to_undirected() + + def __len__(self): + return len(self.di_graph) + + @property + def fully_propagated(self): + """Checks if all data for each node have been transferred + to its parents. With zero latency, this should be the case after running propagate""" + for edge in self.edges.edges: + if len(edge.unsent_data) != 0: + return False + + return True + + +class InformationArchitecture(Architecture): + """The architecture for how information is shared through the network. Node A is " + "connected to Node B if and only if the information A creates by processing and/or " + "sensing is received and opened by B without modification by another node. """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for node in self.all_nodes: + if isinstance(node, RepeaterNode): + raise TypeError("Information architecture should not contain any repeater nodes") + for fusion_node in self.fusion_nodes: + fusion_node.tracker.set_time(self.current_time) + + def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, + **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: + """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ + all_detections = dict() + + # Get rid of ground truths that have not yet happened + # (ie GroundTruthState's with timestamp after self.current_time) + new_ground_truths = set() + for ground_truth_path in ground_truths: + # need an if len(states) == 0 continue condition here? + new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) + + for sensor_node in self.sensor_nodes: + all_detections[sensor_node] = set() + for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): + all_detections[sensor_node].add(detection) + + # Borrowed below from SensorSuite. I don't think it's necessary, but might be something + # we need. If so, will need to define self.attributes_inform + + # attributes_dict = \ + # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) + # for attribute_name in self.attributes_inform} + # + # for detection in all_detections[sensor_node]: + # detection.metadata.update(attributes_dict) + + for data in all_detections[sensor_node]: + # The sensor acquires its own data instantly + sensor_node.update(data.timestamp, data.timestamp, + DataPiece(sensor_node, sensor_node, data, data.timestamp), + 'created') + + return all_detections + + def propagate(self, time_increment: float, failed_edges: Collection = None): + """Performs the propagation of the measurements through the network""" + for edge in self.edges.edges: + if failed_edges and edge in failed_edges: + edge._failed(self.current_time, time_increment) + continue # No data passed along these edges + edge.update_messages(self.current_time) + # fuse goes here? + for data_piece, time_pertaining in edge.unsent_data: + edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) + + # for node in self.processing_nodes: + # node.process() # This should happen when a new message is received + count = 0 + if not self.fully_propagated: + count += 1 + self.propagate(time_increment, failed_edges) + return + + for fuse_node in self.fusion_nodes: + x = threading.Thread(target=fuse_node.fuse) + x.start() + + self.current_time += timedelta(seconds=time_increment) + for fusion_node in self.fusion_nodes: + fusion_node.tracker.set_time(self.current_time) + + +class NetworkArchitecture(Architecture): + """The architecture for how data is propagated through the network. Node A is connected " + "to Node B if and only if A sends its data through B. """ + + def propagate(self, time_increment: float): + # Still have to deal with latency/bandwidth + self.current_time += timedelta(seconds=time_increment) + for node in self.all_nodes: + for parent in self.parents(node): + for data in node.data_held: + parent.update(self.current_time, data) + + +class CombinedArchitecture(Base): + """Contains an information and a network architecture that pertain to the same scenario. """ + information_architecture: InformationArchitecture = Property( + doc="The information architecture for how information is shared. ") + network_architecture: NetworkArchitecture = Property( + doc="The architecture for how data is propagated through the network. Node A is connected " + "to Node B if and only if A sends its data through B. ") + + def propagate(self, time_increment: float): + # First we simulate the network + self.network_architecture.propagate(time_increment) + # Now we want to only pass information along in the information architecture if it + # Was in the information architecture by at least one path. + # Some magic here + failed_edges = [] # return this from n_arch.propagate? + self.information_architecture.propagate(time_increment, failed_edges) diff --git a/stonesoup/architecture/architecture.py b/stonesoup/architecture/architecture.py deleted file mode 100644 index 36e46b048..000000000 --- a/stonesoup/architecture/architecture.py +++ /dev/null @@ -1,483 +0,0 @@ -from abc import abstractmethod -from ..base import Base, Property -from .node import Node, SensorNode, RepeaterNode, FusionNode -from .edge import Edges, DataPiece -from ..types.groundtruth import GroundTruthPath -from ..types.detection import TrueDetection, Clutter - -from typing import List, Collection, Tuple, Set, Union, Dict -import numpy as np -import networkx as nx -import graphviz -from string import ascii_uppercase as auc -from datetime import datetime, timedelta -import threading - - -class Architecture(Base): - edges: Edges = Property( - doc="An Edges object containing all edges. For A to be connected to B we would have an " - "Edge with edge_pair=(A, B) in this object.") - current_time: datetime = Property( - default=datetime.now(), - doc="The time which the instance is at for the purpose of simulation. " - "This is increased by the propagate method. This should be set to the earliest " - "timestep from the ground truth") - name: str = Property( - default=None, - doc="A name for the architecture, to be used to name files and/or title plots. Default is " - "the class name") - force_connected: bool = Property( - default=True, - doc="If True, the undirected version of the graph must be connected, ie. all nodes should " - "be connected via some path. Set this to False to allow an unconnected architecture. " - "Default is True") - - # Below is no longer required with changes to plot - didn't delete in case we want to revert - # to previous method - # font_size: int = Property( - # default=8, - # doc='Font size for node labels') - # node_dim: tuple = Property( - # default=(0.5, 0.5), - # doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.name: - self.name = type(self).__name__ - if not self.current_time: - self.current_time = datetime.now() - - self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) - - if self.force_connected and not self.is_connected and len(self) > 0: - raise ValueError("The graph is not connected. Use force_connected=False, " - "if you wish to override this requirement") - - # Set attributes such as label, colour, shape, etc for each node - last_letters = {'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', - 'RepeaterNode': ''} - for node in self.di_graph.nodes: - if node.label: - label = node.label - else: - label, last_letters = _default_label(node, last_letters) - node.label = label - attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", - "fontsize": f"{node.font_size}", "width": f"{node.node_dim[0]}", - "height": f"{node.node_dim[1]}", "fixedsize": True} - self.di_graph.nodes[node].update(attr) - - def parents(self, node: Node): - """Returns a set of all nodes to which the input node has a direct edge to""" - if node not in self.all_nodes: - raise ValueError("Node not in this architecture") - parents = set() - for other in self.all_nodes: - if (node, other) in self.edges.edge_list: - parents.add(other) - return parents - - def children(self, node: Node): - """Returns a set of all nodes to which the input node has a direct edge from""" - if node not in self.all_nodes: - raise ValueError("Node not in this architecture") - children = set() - for other in self.all_nodes: - if (other, node) in self.edges.edge_list: - children.add(other) - return children - - def sibling_group(self, node: Node): - """Returns a set of siblings of the given node. The given node is included in this set.""" - siblings = set() - for parent in self.parents(node): - for child in self.children(parent): - siblings.add(child) - return siblings - - @property - def shortest_path_dict(self): - g = nx.DiGraph() - for edge in self.edges.edge_list: - g.add_edge(edge[0], edge[1]) - path = nx.all_pairs_shortest_path_length(g) - dpath = {x[0]: x[1] for x in path} - return dpath - - def _parent_position(self, node: Node): - """Returns a tuple of (x_coord, y_coord) giving the location of a node's parent""" - parents = self.parents(node) - if len(parents) == 1: - parent = parents.pop() - else: - raise ValueError("Node has more than one parent") - return parent.position - - @property - def top_nodes(self): - top_nodes = list() - for node in self.all_nodes: - if len(self.parents(node)) == 0: - # This node must be the top level node - top_nodes.append(node) - - return top_nodes - - def number_of_leaves(self, node: Node): - node_leaves = set() - non_leaves = 0 - for leaf_node in self.leaf_nodes: - try: - shortest_path = self.shortest_path_dict[leaf_node][node] - if shortest_path != 0: - node_leaves.add(leaf_node) - except KeyError: - non_leaves += 1 - - if len(node_leaves) == 0: - return 1 - else: - return len(node_leaves) - - @property - def leaf_nodes(self): - leaf_nodes = set() - for node in self.all_nodes: - if len(self.children(node)) == 0: - # This must be a leaf node - leaf_nodes.add(node) - return leaf_nodes - - @abstractmethod - def propagate(self, time_increment: float): - raise NotImplementedError - - @property - def all_nodes(self): - return set(self.di_graph.nodes) - - @property - def sensor_nodes(self): - sensor_nodes = set() - for node in self.all_nodes: - if isinstance(node, SensorNode): - sensor_nodes.add(node) - return sensor_nodes - - @property - def fusion_nodes(self): - processing = set() - for node in self.all_nodes: - if isinstance(node, FusionNode): - processing.add(node) - return processing - - def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, - bgcolour="lightgray", node_style="filled", show_plot=True): - """Creates a pdf plot of the directed graph and displays it - - :param dir_path: The path to save the pdf and .gv files to - :param filename: Name to call the associated files - :param use_positions: - :param plot_title: If a string is supplied, makes this the title of the plot. If True, uses - the name attribute of the graph to title the plot. If False, no title is used. - Default is False - :param bgcolour: String containing the background colour for the plot. - Default is "lightgray". See graphviz attributes for more information. - One alternative is "white" - :param node_style: String containing the node style for the plot. - Default is "filled". See graphviz attributes for more information. - One alternative is "solid" - :return: - """ - if use_positions: - for node in self.di_graph.nodes: - if not isinstance(node.position, Tuple): - raise TypeError("If use_positions is set to True, every node must have a " - "position, given as a Tuple of length 2") - attr = {"pos": f"{node.position[0]},{node.position[1]}!"} - self.di_graph.nodes[node].update(attr) - elif self.is_hierarchical: - - # Find top node and assign location - top_nodes = self.top_nodes - if len(top_nodes) == 1: - top_node = top_nodes[0] - else: - raise ValueError("Graph with more than one top level node provided.") - - top_node.position = (0, 0) - attr = {"pos": f"{top_node.position[0]},{top_node.position[1]}!"} - self.di_graph.nodes[top_node].update(attr) - - # Set of nodes that have been plotted / had their positions updated - plotted_nodes = set() - plotted_nodes.add(top_node) - - # Set of nodes that have been plotted, but need to have parent nodes plotted - layer_nodes = set() - layer_nodes.add(top_node) - - # Initialise a layer count - layer = -1 - while len(plotted_nodes) < len(self.all_nodes): - - # Initialse an empty set to store nodes to be considered in the next iteration - next_layer_nodes = set() - - # Iterate through nodes on the current layer (nodes that have been plotted but have - # child nodes that are not plotted) - for layer_node in layer_nodes: - - # Find children of the parent node - children = self.children(layer_node) - - # Find number of leaf nodes that descend from the parent - n_parent_leaves = self.number_of_leaves(layer_node) - - # Get parent x_loc - parent_x_loc = layer_node.position[0] - - # Get location of left limit of the range that leaf nodes will be plotted in - l_x_loc = parent_x_loc - n_parent_leaves/2 - left_limit = l_x_loc - - for child in children: - # Calculate x_loc of the child node - x_loc = left_limit + self.number_of_leaves(child)/2 - - # Update the left limit - left_limit += self.number_of_leaves(child) - - # Update the position of the child node - child.position = (x_loc, layer) - attr = {"pos": f"{child.position[0]},{child.position[1]}!"} - self.di_graph.nodes[child].update(attr) - - # Add child node to list of nodes to be considered in next iteration, and - # to list of nodes that have been plotted - next_layer_nodes.add(child) - plotted_nodes.add(child) - - # Set list of nodes to be considered next iteration - layer_nodes = next_layer_nodes - - # Update layer count for correct y location - layer -= 1 - - dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() - dot_split = dot.split('\n') - dot_split.insert(1, f"graph [bgcolor={bgcolour}]") - dot_split.insert(1, f"node [style={node_style}]") - dot = "\n".join(dot_split) - if plot_title: - if plot_title is True: - plot_title = self.name - elif not isinstance(plot_title, str): - raise ValueError("Plot title must be a string, or True") - dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{plot_title}\";" + "}" - if not filename: - filename = self.name - viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') - if show_plot: - viz_graph.view() - - @property - def density(self): - """Returns the density of the graph, ie. the proportion of possible edges between nodes - that exist in the graph""" - num_nodes = len(self.all_nodes) - num_edges = len(self.edges) - architecture_density = num_edges / ((num_nodes * (num_nodes - 1)) / 2) - return architecture_density - - @property - def is_hierarchical(self): - """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" - # if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: - no_parents = 0 - one_parent = 0 - multiple_parents = 0 - for node in self.all_nodes: - if len(self.parents(node)) == 0: - no_parents += 1 - elif len(self.parents(node)) == 1: - one_parent += 1 - elif len(self.parents(node)) > 1: - multiple_parents += 1 - - if multiple_parents == 0 and no_parents == 1: - return True - else: - return False - - @property - def is_centralised(self): - n_parents = 0 - for node in self.all_nodes: - if len(node.children) == 0: - n_parents += 1 - - if n_parents == 0: - return True - else: - return False - - @property - def is_connected(self): - return nx.is_connected(self.to_undirected) - - @property - def to_undirected(self): - return self.di_graph.to_undirected() - - def __len__(self): - return len(self.di_graph) - - @property - def fully_propagated(self): - """Checks if all data for each node have been transferred - to its parents. With zero latency, this should be the case after running propagate""" - for edge in self.edges.edges: - if len(edge.unsent_data) != 0: - return False - - return True - - -class InformationArchitecture(Architecture): - """The architecture for how information is shared through the network. Node A is " - "connected to Node B if and only if the information A creates by processing and/or " - "sensing is received and opened by B without modification by another node. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for node in self.all_nodes: - if isinstance(node, RepeaterNode): - raise TypeError("Information architecture should not contain any repeater nodes") - for fusion_node in self.fusion_nodes: - fusion_node.tracker.set_time(self.current_time) - - def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, - **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: - """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ - all_detections = dict() - - # Get rid of ground truths that have not yet happened - # (ie GroundTruthState's with timestamp after self.current_time) - new_ground_truths = set() - for ground_truth_path in ground_truths: - # need an if len(states) == 0 continue condition here? - new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) - - for sensor_node in self.sensor_nodes: - all_detections[sensor_node] = set() - for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): - all_detections[sensor_node].add(detection) - - # Borrowed below from SensorSuite. I don't think it's necessary, but might be something - # we need. If so, will need to define self.attributes_inform - - # attributes_dict = \ - # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) - # for attribute_name in self.attributes_inform} - # - # for detection in all_detections[sensor_node]: - # detection.metadata.update(attributes_dict) - - for data in all_detections[sensor_node]: - # The sensor acquires its own data instantly - sensor_node.update(data.timestamp, data.timestamp, - DataPiece(sensor_node, sensor_node, data, data.timestamp), - 'created') - - return all_detections - - def propagate(self, time_increment: float, failed_edges: Collection = None): - """Performs the propagation of the measurements through the network""" - for edge in self.edges.edges: - if failed_edges and edge in failed_edges: - edge._failed(self.current_time, time_increment) - continue # No data passed along these edges - edge.update_messages(self.current_time) - # fuse goes here? - for data_piece, time_pertaining in edge.unsent_data: - edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) - - # for node in self.processing_nodes: - # node.process() # This should happen when a new message is received - count = 0 - if not self.fully_propagated: - count += 1 - self.propagate(time_increment, failed_edges) - return - - for fuse_node in self.fusion_nodes: - x = threading.Thread(target=fuse_node.fuse) - x.start() - - self.current_time += timedelta(seconds=time_increment) - for fusion_node in self.fusion_nodes: - fusion_node.tracker.set_time(self.current_time) - - -class NetworkArchitecture(Architecture): - """The architecture for how data is propagated through the network. Node A is connected " - "to Node B if and only if A sends its data through B. """ - - def propagate(self, time_increment: float): - # Still have to deal with latency/bandwidth - self.current_time += timedelta(seconds=time_increment) - for node in self.all_nodes: - for parent in self.parents(node): - for data in node.data_held: - parent.update(self.current_time, data) - - -class CombinedArchitecture(Base): - """Contains an information and a network architecture that pertain to the same scenario. """ - information_architecture: InformationArchitecture = Property( - doc="The information architecture for how information is shared. ") - network_architecture: NetworkArchitecture = Property( - doc="The architecture for how data is propagated through the network. Node A is connected " - "to Node B if and only if A sends its data through B. ") - - def propagate(self, time_increment: float): - # First we simulate the network - self.network_architecture.propagate(time_increment) - # Now we want to only pass information along in the information architecture if it - # Was in the information architecture by at least one path. - # Some magic here - failed_edges = [] # return this from n_arch.propagate? - self.information_architecture.propagate(time_increment, failed_edges) - - -def _default_label(node, last_letters): - """Utility function to generate default labels for nodes, where none are given - Takes a node, and a dictionary with the letters last used for each class, - ie `last_letters['Node']` might return 'AA', meaning the last Node was labelled 'Node AA'""" - node_type = type(node).__name__ - type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' - new_letters = _default_letters(type_letters) - last_letters[node_type] = new_letters - return node_type + ' ' + new_letters, last_letters - - -def _default_letters(type_letters) -> str: - if type_letters == '': - return 'A' - count = 0 - letters_list = [*type_letters] - # Move through string from right to left and shift any Z's up to A's - while letters_list[-1 - count] == 'Z': - letters_list[-1 - count] = 'A' - count += 1 - if count == len(letters_list): - return 'A' * (count + 1) - # Shift current letter up by one - current_letter = letters_list[-1 - count] - letters_list[-1 - count] = auc[auc.index(current_letter) + 1] - new_letters = ''.join(letters_list) - return new_letters diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 319a2ab0c..5ae947bc8 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -7,6 +7,7 @@ from ..types.hypothesis import Hypothesis from .functions import _dict_set +from collections.abc import Collection from typing import Union, Tuple, List, TYPE_CHECKING from datetime import datetime, timedelta from queue import Queue @@ -102,34 +103,40 @@ def failed(self, current_time, duration): self.time_ranges_failed.append(TimeRange(current_time, end_time)) @property - def child(self): + def sender(self): return self.nodes[0] @property - def parent(self): + def recipient(self): return self.nodes[1] @property def ovr_latency(self): """Overall latency including the two Nodes and the edge latency.""" - return self.child.latency + self.edge_latency + self.parent.latency + return self.sender.latency + self.edge_latency + self.recipient.latency @property def unsent_data(self): """Data held by the child that has not been sent to the parent.""" unsent = [] for status in ["fused", "created"]: - for time_pertaining in self.child.data_held[status]: - for data_piece in self.child.data_held[status][time_pertaining]: - if self.parent not in data_piece.sent_to: + for time_pertaining in self.sender.data_held[status]: + for data_piece in self.sender.data_held[status][time_pertaining]: + if self.recipient not in data_piece.sent_to: unsent.append((data_piece, time_pertaining)) return unsent -class Edges(Base): +class Edges(Base, Collection): """Container class for Edge""" edges: List[Edge] = Property(doc="List of Edge objects", default=None) + def __iter__(self): + return self.edges.__iter__() + + def __contains__(self, item): + return item in self.edges + def add(self, edge): self.edges.append(edge) @@ -176,12 +183,12 @@ def __init__(self, *args, **kwargs): self.status = "sending" @property - def generator_node(self): - return self.edge.child + def sender_node(self): + return self.edge.sender @property def recipient_node(self): - return self.edge.parent + return self.edge.recipient @property def arrival_time(self): @@ -190,9 +197,9 @@ def arrival_time(self): def update(self, current_time): progress = (current_time - self.time_sent).total_seconds() - if progress < self.edge.child.latency: + if progress < self.edge.sender.latency: self.status = "sending" - elif progress < self.edge.child.latency + self.edge.edge_latency: + elif progress < self.edge.sender.latency + self.edge.edge_latency: self.status = "transferring" elif progress < self.edge.ovr_latency: self.status = "receiving" diff --git a/stonesoup/architecture/functions.py b/stonesoup/architecture/functions.py index 0e8872684..1f4d6e8df 100644 --- a/stonesoup/architecture/functions.py +++ b/stonesoup/architecture/functions.py @@ -1,3 +1,6 @@ +from string import ascii_uppercase as auc + + def _dict_set(my_dict, value, key1, key2=None): """Utility function to add value to my_dict at the specified key(s) Returns True iff the set increased in size, ie the value was new to its position""" @@ -24,3 +27,32 @@ def _dict_set(my_dict, value, key1, key2=None): else: my_dict[key1] = {value} return True, my_dict + + +def _default_label(node, last_letters): + """Utility function to generate default labels for nodes, where none are given + Takes a node, and a dictionary with the letters last used for each class, + ie `last_letters['Node']` might return 'AA', meaning the last Node was labelled 'Node AA'""" + node_type = type(node).__name__ + type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' + new_letters = _default_letters(type_letters) + last_letters[node_type] = new_letters + return node_type + ' ' + new_letters, last_letters + + +def _default_letters(type_letters) -> str: + if type_letters == '': + return 'A' + count = 0 + letters_list = [*type_letters] + # Move through string from right to left and shift any Z's up to A's + while letters_list[-1 - count] == 'Z': + letters_list[-1 - count] = 'A' + count += 1 + if count == len(letters_list): + return 'A' * (count + 1) + # Shift current letter up by one + current_letter = letters_list[-1 - count] + letters_list[-1 - count] = auc[auc.index(current_letter) + 1] + new_letters = ''.join(letters_list) + return new_letters diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index d95822140..2de551a99 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -29,7 +29,7 @@ class Node(Base): default=None, doc='Shape used to display nodes') font_size: int = Property( - default=None, + default=5, doc='Font size for node labels') node_dim: tuple = Property( default=None, @@ -62,17 +62,15 @@ def update(self, time_pertaining, time_arrived, data_piece, category, track=None class SensorNode(Node): """A node corresponding to a Sensor. Fresh data is created here""" sensor: Sensor = Property(doc="Sensor corresponding to this node") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#1f77b4' - if not self.shape: - self.shape = 'oval' - if not self.font_size: - self.font_size = 5 - if not self.node_dim: - self.node_dim = (0.5, 0.3) + colour: str = Property( + default='#1f77b4', + doc='Colour to be displayed on graph. Default is the hex colour code #1f77b4') + shape: str = Property( + default='oval', + doc='Shape used to display nodes. Default is an oval') + node_dim: tuple = Property( + default=(0.5, 0.3), + doc='Width and height of nodes for graph icons. Default is (0.5, 0.3)') class FusionNode(Node): @@ -88,17 +86,18 @@ class FusionNode(Node): doc="Tracker for associating tracks at the node") tracks: set = Property(default=None, doc="Set of tracks tracked by the fusion node") + colour: str = Property( + default='#006400', + doc='Colour to be displayed on graph. Default is the hex colour code #006400') + shape: str = Property( + default='hexagon', + doc='Shape used to display nodes. Default is a hexagon') + node_dim: tuple = Property( + default=(0.6, 0.3), + doc='Width and height of nodes for graph icons. Default is (0.6, 0.3)') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#006400' - if not self.shape: - self.shape = 'hexagon' - if not self.font_size: - self.font_size = 5 - if not self.node_dim: - self.node_dim = (0.6, 0.3) self.tracks = set() # Set of tracks this Node has recorded def fuse(self): @@ -120,30 +119,25 @@ def fuse(self): class SensorFusionNode(SensorNode, FusionNode): """A node that is both a sensor and also processes data""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.colour in ['#006400', '#1f77b4']: - self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating - if self.shape in ['oval', 'hexagon']: - self.shape = 'rectangle' - if not self.font_size: - self.font_size = 5 - if not self.node_dim: - self.node_dim = (0.1, 0.3) + colour: str = Property( + default='#909090', + doc='Colour to be displayed on graph. Default is the hex colour code #909090') + shape: str = Property( + default='rectangle', + doc='Shape used to display nodes. Default is a rectangle') + node_dim: tuple = Property( + default=(0.1, 0.3), + doc='Width and height of nodes for graph icons. Default is (0.1, 0.3)') class RepeaterNode(Node): """A node which simply passes data along to others, without manipulating the data itself. """ - # Latency property could go here - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#ff7f0e' - if not self.shape: - self.shape = 'circle' - if not self.font_size: - self.font_size = 5 - if not self.node_dim: - self.node_dim = (0.5, 0.3) - - + colour: str = Property( + default='#ff7f0e', + doc='Colour to be displayed on graph. Default is the hex colour code #ff7f0e') + shape: str = Property( + default='circle', + doc='Shape used to display nodes. Default is a circle') + node_dim: tuple = Property( + default=(0.5, 0.3), + doc='Width and height of nodes for graph icons. Default is (0.5, 0.3)') diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 4445e4c6a..220b43ec6 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -3,7 +3,7 @@ import numpy as np -from ..architecture import InformationArchitecture +from stonesoup.architecture import InformationArchitecture from ..edge import Edge, Edges from ..node import RepeaterNode, SensorNode from ...sensor.categorical import HMMSensor diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index 1e8bf10ff..847e2528c 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -1,8 +1,9 @@ import datetime +from abc import ABC from stonesoup.architecture.edge import FusionQueue from .base import Tracker -from ..base import Property +from ..base import Property, Base from stonesoup.buffered_generator import BufferedGenerator from stonesoup.dataassociator.tracktotrack import TrackToTrackCounting from stonesoup.reader.base import DetectionReader @@ -13,7 +14,7 @@ from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder -class FusionTracker(Tracker): +class FusionTracker(Tracker, ABC): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tracks = set() diff --git a/stonesoup/types/architecture.py b/stonesoup/types/architecture.py deleted file mode 100644 index 036d2048d..000000000 --- a/stonesoup/types/architecture.py +++ /dev/null @@ -1,711 +0,0 @@ -from abc import abstractmethod -from ..base import Property, Base -from .base import Type -from ..sensor.sensor import Sensor -from ..types.groundtruth import GroundTruthPath -from ..types.detection import TrueDetection, Clutter, Detection -from ..types.hypothesis import Hypothesis -from ..types.track import Track -from ..hypothesiser.base import Hypothesiser -from ..predictor.base import Predictor -from ..updater.base import Updater -from ..dataassociator.base import DataAssociator -from ..initiator.base import Initiator -from ..deleter.base import Deleter -from ..tracker.base import Tracker -from ..types.time import TimeRange - -from typing import List, Collection, Tuple, Set, Union, Dict -import numpy as np -import networkx as nx -import graphviz -from string import ascii_uppercase as auc -from datetime import datetime, timedelta - - -class Node(Type): - """Base node class. Should be abstract""" - latency: float = Property( - doc="Contribution to edge latency stemming from this node", - default=0) - label: str = Property( - doc="Label to be displayed on graph", - default=None) - position: Tuple[float] = Property( - default=None, - doc="Cartesian coordinates for node") - colour: str = Property( - default=None, - doc='Colour to be displayed on graph') - shape: str = Property( - default=None, - doc='Shape used to display nodes') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.data_held = {"fused": {}, "created": {}, "unfused": {}} - # Node no longer handles messages. All done by Edge - - def update(self, time_pertaining, time_arrived, data_piece, category, track=None): - if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): - raise TypeError("Times must be datetime objects") - if not track: - if not isinstance(data_piece.data, Detection) and not isinstance(data_piece.data, Track): - raise TypeError(f"Data provided without accompanying Track must be a Detection or a Track, not a " - f"{type(data_piece.data).__name__}") - added, self.data_held[category] = _dict_set(self.data_held[category], - DataPiece(self, data_piece.originator, data_piece.data, - time_arrived), - time_pertaining) - else: - if not isinstance(data_piece.data, Hypothesis): - raise TypeError("Data provided with Track must be a Hypothesis") - added, self.data_held[category] = _dict_set(self.data_held[category], - DataPiece(self, data_piece.originator, data_piece.data, - time_arrived, track), - time_pertaining) - - return added - - -class SensorNode(Node): - """A node corresponding to a Sensor. Fresh data is created here""" - sensor: Sensor = Property(doc="Sensor corresponding to this node") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#1f77b4' - if not self.shape: - self.shape = 'square' - - -class FusionNode(Node): - """A node that does not measure new data, but does process data it receives""" - predictor: Predictor = Property( - doc="The predictor used by this node. ") - updater: Updater = Property( - doc="The updater used by this node. ") - hypothesiser: Hypothesiser = Property( - doc="The hypothesiser used by this node. ") - data_associator: DataAssociator = Property( - doc="The data associator used by this node. ") - initiator: Initiator = Property( - doc="The initiator used by this node") - deleter: Deleter = Property( - doc="The deleter used by this node") - tracker: Tracker = Property( - doc="Tracker used by this node. Only trackers which can handle the types of data fused by " - "the node will work.") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#006400' - if not self.shape: - self.shape = 'hexagon' - self.tracks = set() # Set of tracks this Node has recorded - - def process(self): - # deleting this soon - unprocessed_times = {time for time in self.unprocessed_data} | \ - {time for track in self.unprocessed_hypotheses - for time in self.unprocessed_hypotheses[track]} - for time in unprocessed_times: - try: - detect_hypotheses = self.data_associator.associate(self.tracks, - self.unprocessed_data[time], - time) - except KeyError: - detect_hypotheses = False - - associated_detections = set() - for track in self.tracks: - if not detect_hypotheses and track not in self.unprocessed_hypotheses: - # If this track has no new data - continue - detect_hypothesis = detect_hypotheses[track] if detect_hypotheses else False - try: - # We deliberately re-use old hypotheses. If we just used the unprocessed ones - # then information would be lost - track_hypotheses = list(self.hypotheses_held[track][time]) - if detect_hypothesis: - hypothesis = mean_combine([detect_hypothesis] + track_hypotheses, time) - else: - hypothesis = mean_combine(track_hypotheses, time) - except (TypeError, KeyError): - hypothesis = detect_hypothesis - - _, self.hypotheses_held = _dict_set(self.hypotheses_held, hypothesis, track, time) - _, self.processed_hypotheses = _dict_set(self.processed_hypotheses, - hypothesis, track, time) - - if hypothesis.measurement: - post = self.updater.update(hypothesis) - _update_track(track, post, time) - associated_detections.add(hypothesis.measurement) - else: # Keep prediction - _update_track(track, hypothesis.prediction, time) - - # Create or delete tracks - self.tracks -= self.deleter.delete_tracks(self.tracks) - if time in self.unprocessed_data: # If we had any detections - self.tracks |= self.initiator.initiate(self.unprocessed_data[time] - - associated_detections, time) - - # Send the unprocessed data that was just processed to processed_data - for time in self.data_held['unprocessed']: - for data in self.data_held['unprocessed'][time]: - _, self.data_held['processed'] = _dict_set(self.data_held['processed'], data, time) - - self.data_held['unprocessed'] = set() - return - - -class SensorFusionNode(SensorNode, FusionNode): - """A node that is both a sensor and also processes data""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.colour in ['#006400', '#1f77b4']: - self.colour = '#909090' # attr dict in Architecture.__init__ also needs updating - if self.shape in ['square', 'hexagon']: - self.shape = 'octagon' - - -class RepeaterNode(Node): - """A node which simply passes data along to others, without manipulating the data itself. """ - # Latency property could go here - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.colour: - self.colour = '#ff7f0e' - if not self.shape: - self.shape = 'circle' - - -class DataPiece(Type): - """A piece of data for use in an architecture. Sent via Messages, and stored in a Node's data_held""" - node: Node = Property( - doc="The Node this data piece belongs to") - originator: Node = Property( - doc="The node which first created this data, ie by sensing or fusing information together. " - "If the data is simply passed along the chain, the originator remains unchanged. ") - data: Union[Detection, Track, Hypothesis] = Property( - doc="A Detection, Track, or Hypothesis") - time_arrived: datetime = Property( - doc="The time at which this piece of data was received by the Node, either by Message or by sensing.") - track: Track = Property( - doc="The Track in the event of data being a Hypothesis", - default=None) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.sent_to = set() # all Nodes the data_piece has been sent to, to avoid duplicates - - -class Edge(Type): - """Comprised of two connected Nodes""" - nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (ancestor, descendant") - edge_latency: float = Property(doc="The latency stemming from the edge itself, " - "and not either of the nodes", - default=0) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.messages_held = {"pending": {}, # For pending, messages indexed by time sent. - "received": {}} # For received, by time received - self.time_ranges_failed = [] # List of time ranges during which this edge was failed - - def send_message(self, data_piece, time_pertaining, time_sent): - if not isinstance(data_piece, DataPiece): - raise TypeError("Message info must be one of the following types: " - "Detection, Hypothesis or Track") - # Add message to 'pending' dict of edge - message = Message(self, time_pertaining, time_sent, data_piece) - _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) - # ensure message not re-sent - data_piece.sent_to.add(self.nodes[1]) - - def update_messages(self, current_time): - # Check info type is what we expect - to_remove = set() # Needed as we can't change size of a set during iteration - for time in self.messages_held['pending']: - for message in self.messages_held['pending'][time]: - message.update(current_time) - if message.status == 'received': - # Then the latency has passed and message has been received - # Move message from pending to received messages in edge - to_remove.add((time, message)) - _, self.messages_held = _dict_set(self.messages_held, message, - 'received', message.arrival_time) - # Update - message.recipient_node.update(message.time_pertaining, message.arrival_time, - message.data_piece, "unfused") - - for time, message in to_remove: - self.messages_held['pending'][time].remove(message) - - def _failed(self, current_time, duration): - """Keeps track of when this edge was failed using the time_ranges_failed property. """ - end_time = current_time + timedelta(duration) - self.time_ranges_failed.append(TimeRange(current_time, end_time)) - - @property - def ancestor(self): - return self.nodes[0] - - @property - def descendant(self): - return self.nodes[1] - - @property - def ovr_latency(self): - """Overall latency including the two Nodes and the edge latency.""" - return self.ancestor.latency + self.edge_latency + self.descendant.latency - - @property - def unsent_data(self): - """Data held by the ancestor that has not been sent to the descendant.""" - unsent = [] - for status in ["fused", "created"]: - for time_pertaining in self.ancestor.data_held[status]: - for data_piece in self.ancestor.data_held[status][time_pertaining]: - if self.descendant not in data_piece.sent_to: - unsent.append((data_piece, time_pertaining)) - return unsent - - -class Edges(Type): - """Container class for Edge""" - edges: List[Edge] = Property(doc="List of Edge objects", default=None) - - def add(self, edge): - self.edges.append(edge) - - def get(self, node_pair): - if not isinstance(node_pair, Tuple) and all(isinstance(node, Node) for node in node_pair): - raise TypeError("Must supply a tuple of nodes") - if not len(node_pair) == 2: - raise ValueError("Incorrect tuple length. Must be of length 2") - for edge in self.edges: - if edge.nodes == node_pair: - # Assume this is the only match? - return edge - return None - - @property - def edge_list(self): - """Returns a list of tuples in the form (ancestor, descendant)""" - edge_list = [] - for edge in self.edges: - edge_list.append(edge.nodes) - return edge_list - - def __len__(self): - return len(self.edges) - - -class Message(Type): - """A message, containing a piece of information, that gets propagated between two Nodes. - Messages are opened by nodes that are a descendant of the node that sent the message""" - edge: Edge = Property( - doc="The directed edge containing the sender and receiver of the message") - time_pertaining: datetime = Property( - doc="The latest time for which the data pertains. For a Detection, this would be the time " - "of the Detection, or for a Track this is the time of the last State in the Track. " - "Different from time_sent when data is passed on that was not generated by this " - "Node's ancestor") - time_sent: datetime = Property( - doc="Time at which the message was sent") - data_piece: DataPiece = Property( - doc="Info that the sent message contains") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.status = "sending" - - @property - def generator_node(self): - return self.edge.ancestor - - @property - def recipient_node(self): - return self.edge.descendant - - @property - def arrival_time(self): - # TODO: incorporate failed time ranges here. Not essential for a first PR. Could do with merging of PR #664 - return self.time_sent + timedelta(seconds=self.edge.ovr_latency) - - def update(self, current_time): - progress = (current_time - self.time_sent).total_seconds() - if progress < self.edge.ancestor.latency: - self.status = "sending" - elif progress < self.edge.ancestor.latency + self.edge.edge_latency: - self.status = "transferring" - elif progress < self.edge.ovr_latency: - self.status = "receiving" - else: - self.status = "received" - - -class Architecture(Type): - edges: Edges = Property( - doc="An Edges object containing all edges. For A to be connected to B we would have an " - "Edge with edge_pair=(A, B) in this object.") - current_time: datetime = Property( - doc="The time which the instance is at for the purpose of simulation. " - "This is increased by the propagate method. This should be set to the earliest timestep " - "from the ground truth") - name: str = Property( - default=None, - doc="A name for the architecture, to be used to name files and/or title plots. Default is " - "the class name") - force_connected: bool = Property( - default=True, - doc="If True, the undirected version of the graph must be connected, ie. all nodes should " - "be connected via some path. Set this to False to allow an unconnected architecture. " - "Default is True") - font_size: int = Property( - default=8, - doc='Font size for node labels') - node_dim: tuple = Property( - default=(0.5, 0.5), - doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.name: - self.name = type(self).__name__ - if not self.current_time: - self.current_time = datetime.now() - - self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) - - if self.force_connected and not self.is_connected and len(self) > 0: - raise ValueError("The graph is not connected. Use force_connected=False, " - "if you wish to override this requirement") - - # Set attributes such as label, colour, shape, etc for each node - last_letters = {'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', - 'RepeaterNode': ''} - for node in self.di_graph.nodes: - if node.label: - label = node.label - else: - label, last_letters = _default_label(node, last_letters) - node.label = label - attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", - "fontsize": f"{self.font_size}", "width": f"{self.node_dim[0]}", - "height": f"{self.node_dim[1]}", "fixedsize": True} - self.di_graph.nodes[node].update(attr) - - def descendants(self, node: Node): - """Returns a set of all nodes to which the input node has a direct edge to""" - if node not in self.all_nodes: - raise ValueError("Node not in this architecture") - descendants = set() - for other in self.all_nodes: - if (node, other) in self.edges.edge_list: - descendants.add(other) - return descendants - - def ancestors(self, node: Node): - """Returns a set of all nodes to which the input node has a direct edge from""" - if node not in self.all_nodes: - raise ValueError("Node not in this architecture") - ancestors = set() - for other in self.all_nodes: - if (other, node) in self.edges.edge_list: - ancestors.add(other) - return ancestors - - @abstractmethod - def propagate(self, time_increment: float): - raise NotImplementedError - - @property - def all_nodes(self): - return set(self.di_graph.nodes) - - @property - def sensor_nodes(self): - sensor_nodes = set() - for node in self.all_nodes: - if isinstance(node, SensorNode): - sensor_nodes.add(node) - return sensor_nodes - - @property - def processing_nodes(self): - processing = set() - for node in self.all_nodes: - if isinstance(node, FusionNode): - processing.add(node) - return processing - - def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, - bgcolour="lightgray", node_style="filled"): - """Creates a pdf plot of the directed graph and displays it - - :param dir_path: The path to save the pdf and .gv files to - :param filename: Name to call the associated files - :param use_positions: - :param plot_title: If a string is supplied, makes this the title of the plot. If True, uses - the name attribute of the graph to title the plot. If False, no title is used. - Default is False - :param bgcolour: String containing the background colour for the plot. - Default is "lightgray". See graphviz attributes for more information. - One alternative is "white" - :param node_style: String containing the node style for the plot. - Default is "filled". See graphviz attributes for more information. - One alternative is "solid" - :return: - """ - if use_positions: - for node in self.di_graph.nodes: - if not isinstance(node.position, Tuple): - raise TypeError("If use_positions is set to True, every node must have a " - "position, given as a Tuple of length 2") - attr = {"pos": f"{node.position[0]},{node.position[1]}!"} - self.di_graph.nodes[node].update(attr) - dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() - dot_split = dot.split('\n') - dot_split.insert(1, f"graph [bgcolor={bgcolour}]") - dot_split.insert(1, f"node [style={node_style}]") - dot = "\n".join(dot_split) - if plot_title: - if plot_title is True: - plot_title = self.name - elif not isinstance(plot_title, str): - raise ValueError("Plot title must be a string, or True") - dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{plot_title}\";" + "}" - if not filename: - filename = self.name - viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') - viz_graph.view() - - @property - def density(self): - """Returns the density of the graph, ie. the proportion of possible edges between nodes - that exist in the graph""" - num_nodes = len(self.all_nodes) - num_edges = len(self.edges) - architecture_density = num_edges/((num_nodes*(num_nodes-1))/2) - return architecture_density - - @property - def is_hierarchical(self): - """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" - if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: - return False - else: - return True - - @property - def is_connected(self): - return nx.is_connected(self.to_undirected) - - @property - def to_undirected(self): - return self.di_graph.to_undirected() - - def __len__(self): - return len(self.di_graph) - - @property - def fully_propagated(self): - """Checks if all data for each node have been transferred - to its descendants. With zero latency, this should be the case after running propagate""" - for edge in self.edges.edges: - if len(edge.unsent_data) != 0: - return False - - return True - - -class InformationArchitecture(Architecture): - """The architecture for how information is shared through the network. Node A is " - "connected to Node B if and only if the information A creates by processing and/or " - "sensing is received and opened by B without modification by another node. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for node in self.all_nodes: - if isinstance(node, RepeaterNode): - raise TypeError("Information architecture should not contain any repeater nodes") - - def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, - **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: - """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ - all_detections = dict() - - # Get rid of ground truths that have not yet happened - # (ie GroundTruthState's with timestamp after self.current_time) - new_ground_truths = set() - for ground_truth_path in ground_truths: - # need an if len(states) == 0 continue condition here? - new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) - - for sensor_node in self.sensor_nodes: - all_detections[sensor_node] = set() - for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): - all_detections[sensor_node].add(detection) - - # Borrowed below from SensorSuite. I don't think it's necessary, but might be something - # we need. If so, will need to define self.attributes_inform - - # attributes_dict = \ - # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) - # for attribute_name in self.attributes_inform} - # - # for detection in all_detections[sensor_node]: - # detection.metadata.update(attributes_dict) - - for data in all_detections[sensor_node]: - # The sensor acquires its own data instantly - sensor_node.update(data.timestamp, data.timestamp, DataPiece(sensor_node, sensor_node, data, - data.timestamp), "created") - - return all_detections - - def propagate(self, time_increment: float, failed_edges: Collection = None): - """Performs the propagation of the measurements through the network""" - for edge in self.edges.edges: - if failed_edges and edge in failed_edges: - edge._failed(self.current_time, time_increment) - continue # No data passed along these edges - edge.update_messages(self.current_time) - # fuse goes here? - for data_piece, time_pertaining in edge.unsent_data: - edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) - - # for node in self.processing_nodes: - # node.process() # This should happen when a new message is received - count = 0 - if not self.fully_propagated: - count += 1 - self.propagate(time_increment, failed_edges) - return - self.current_time += timedelta(seconds=time_increment) - - -class NetworkArchitecture(Architecture): - """The architecture for how data is propagated through the network. Node A is connected " - "to Node B if and only if A sends its data through B. """ - def propagate(self, time_increment: float): - # Still have to deal with latency/bandwidth - self.current_time += timedelta(seconds=time_increment) - for node in self.all_nodes: - for descendant in self.descendants(node): - for data in node.data_held: - descendant.update(self.current_time, data) - - -class CombinedArchitecture(Type): - """Contains an information and a network architecture that pertain to the same scenario. """ - information_architecture: InformationArchitecture = Property( - doc="The information architecture for how information is shared. ") - network_architecture: NetworkArchitecture = Property( - doc="The architecture for how data is propagated through the network. Node A is connected " - "to Node B if and only if A sends its data through B. ") - - def propagate(self, time_increment: float): - # First we simulate the network - self.network_architecture.propagate(time_increment) - # Now we want to only pass information along in the information architecture if it - # Was in the information architecture by at least one path. - # Some magic here - failed_edges = [] # return this from n_arch.propagate? - self.information_architecture.propagate(time_increment, failed_edges) - - -def _default_label(node, last_letters): - """Utility function to generate default labels for nodes, where none are given - Takes a node, and a dictionary with the letters last used for each class, - ie `last_letters['Node']` might return 'AA', meaning the last Node was labelled 'Node AA'""" - node_type = type(node).__name__ - type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' - new_letters = _default_letters(type_letters) - last_letters[node_type] = new_letters - return node_type + ' ' + new_letters, last_letters - - -def _default_letters(type_letters) -> str: - if type_letters == '': - return 'A' - count = 0 - letters_list = [*type_letters] - # Move through string from right to left and shift any Z's up to A's - while letters_list[-1 - count] == 'Z': - letters_list[-1 - count] = 'A' - count += 1 - if count == len(letters_list): - return 'A' * (count + 1) - # Shift current letter up by one - current_letter = letters_list[-1 - count] - letters_list[-1 - count] = auc[auc.index(current_letter) + 1] - new_letters = ''.join(letters_list) - return new_letters - - -def mean_combine(objects: List, current_time: datetime = None): - """Combine a list of objects of the same type by averaging all numbers""" - this_type = type(objects[0]) - if any(type(obj) is not this_type for obj in objects): - raise TypeError("Objects must be of identical type") - - new_values = dict() - for name in type(objects[0]).properties: - value = getattr(objects[0], name) - if isinstance(value, (int, float, complex)) and not isinstance(value, bool): - # average them all - new_values[name] = np.mean([getattr(obj, name) for obj in objects]) - elif isinstance(value, datetime): - # Take the input time, as we likely want the current simulation time - new_values[name] = current_time - elif isinstance(value, Base): # if it's a Stone Soup object - # recurse - new_values[name] = mean_combine([getattr(obj, name) for obj in objects]) - else: - # just take 1st value - new_values[name] = getattr(objects[0], name) - - return this_type.__init__(**new_values) - - -def _dict_set(my_dict, value, key1, key2=None): - """Utility function to add value to my_dict at the specified key(s) - Returns True iff the set increased in size, ie the value was new to its position""" - if not my_dict: - if key2: - my_dict = {key1: {key2: {value}}} - else: - my_dict = {key1: {value}} - elif key2: - if key1 in my_dict: - if key2 in my_dict[key1]: - old_len = len(my_dict[key1][key2]) - my_dict[key1][key2].add(value) - return len(my_dict[key1][key2]) == old_len + 1, my_dict - else: - my_dict[key1][key2] = {value} - else: - my_dict[key1] = {key2: {value}} - else: - if key1 in my_dict: - old_len = len(my_dict[key1]) - my_dict[key1].add(value) - return len(my_dict[key1]) == old_len + 1, my_dict - else: - my_dict[key1] = {value} - return True, my_dict - - -def _update_track(track, state, time): - for state_num in range(len(track)): - if time == track[state_num].timestamp: - track[state_num] = state - return - track.append(state) - diff --git a/stonesoup/types/state.py b/stonesoup/types/state.py index bce9026ae..a1ee7323b 100644 --- a/stonesoup/types/state.py +++ b/stonesoup/types/state.py @@ -654,7 +654,7 @@ def __init__(self, *args, **kwargs): StateVectors([particle.state_vector for particle in self.particle_list]) self.weight = \ np.array([Probability(particle.weight) for particle in self.particle_list]) - parent_list = [particle.parent for particle in self.particle_list] + parent_list = [particle.recipient for particle in self.particle_list] if parent_list.count(None) == 0: self.parent = ParticleState(None, particle_list=parent_list) diff --git a/stonesoup/types/tests/test_architecture.py b/stonesoup/types/tests/test_architecture.py deleted file mode 100644 index 3acf66e42..000000000 --- a/stonesoup/types/tests/test_architecture.py +++ /dev/null @@ -1,374 +0,0 @@ -from ..architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ - CombinedArchitecture, FusionNode, RepeaterNode, SensorNode, Node, SensorFusionNode, Edge, \ - Edges, Message, _dict_set - -from ...sensor.base import PlatformMountable -from stonesoup.models.measurement.categorical import MarkovianMeasurementModel -from stonesoup.models.transition.categorical import MarkovianTransitionModel -from stonesoup.types.groundtruth import CategoricalGroundTruthState -from stonesoup.types.state import CategoricalState -from stonesoup.types.groundtruth import GroundTruthPath -from stonesoup.sensor.categorical import HMMSensor -from stonesoup.predictor.categorical import HMMPredictor -from stonesoup.updater.categorical import HMMUpdater -from stonesoup.hypothesiser.categorical import HMMHypothesiser -from stonesoup.dataassociator.neighbour import GNNWith2DAssignment -from stonesoup.initiator.categorical import SimpleCategoricalMeasurementInitiator -from stonesoup.deleter.time import UpdateTimeStepsDeleter -from stonesoup.tracker.simple import SingleTargetTracker -from stonesoup.tracker.tests.conftest import detector # a useful fixture - -from datetime import datetime, timedelta -import numpy as np -import matplotlib.pyplot as plt - -import pytest - - -@pytest.fixture -def params(): - transition_matrix = np.array([[0.8, 0.2], # P(bike | bike), P(bike | car) - [0.4, 0.6]]) # P(car | bike), P(car | car) - category_transition = MarkovianTransitionModel(transition_matrix=transition_matrix) - start = datetime.now() - - hidden_classes = ['bike', 'car'] - ground_truths = list() - for i in range(1, 1): # 4 targets - state_vector = np.zeros(2) # create a vector with 2 zeroes - state_vector[ - np.random.choice(2, 1, p=[1 / 2, 1 / 2])] = 1 # pick a random class out of the 2 - ground_truth_state = CategoricalGroundTruthState(state_vector, - timestamp=start, - categories=hidden_classes) - - ground_truth = GroundTruthPath([ground_truth_state], id=f"GT{i}") - - for _ in range(10): - new_vector = category_transition.function(ground_truth[-1], - noise=True, - time_interval=timedelta(seconds=1)) - new_state = CategoricalGroundTruthState( - new_vector, - timestamp=ground_truth[-1].timestamp + timedelta(seconds=1), - categories=hidden_classes - ) - - ground_truth.append(new_state) - ground_truths.append(ground_truth) - - e = np.array([[0.8, 0.1], # P(small | bike), P(small | car) - [0.19, 0.3], # P(medium | bike), P(medium | car) - [0.01, 0.6]]) # P(large | bike), P(large | car) - model = MarkovianMeasurementModel(emission_matrix=e, - measurement_categories=['small', 'medium', 'large']) - hmm_sensor = HMMSensor(measurement_model=model) - predictor = HMMPredictor(category_transition) - updater = HMMUpdater() - hypothesiser = HMMHypothesiser(predictor=predictor, updater=updater) - data_associator = GNNWith2DAssignment(hypothesiser) - prior = CategoricalState([1 / 2, 1 / 2], categories=hidden_classes) - initiator = SimpleCategoricalMeasurementInitiator(prior_state=prior, updater=updater) - deleter = UpdateTimeStepsDeleter(2) - tracker = SingleTargetTracker(initiator, deleter, detector, data_associator, updater) # Needs to be filled in - - nodes = SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor), SensorNode(hmm_sensor),\ - SensorNode(sensor=hmm_sensor), SensorNode(sensor=hmm_sensor) - lat_nodes = SensorNode(sensor=hmm_sensor, latency=0.3), SensorNode(sensor=hmm_sensor, latency=0.8975), SensorNode(hmm_sensor, latency=0.356),\ - SensorNode(sensor=hmm_sensor, latency=0.7), SensorNode(sensor=hmm_sensor, latency=0.5) - big_nodes = SensorFusionNode(sensor=hmm_sensor, - predictor=predictor, - updater=updater, - hypothesiser=hypothesiser, - data_associator=data_associator, - initiator=initiator, - deleter=deleter, - tracker=tracker), \ - FusionNode(predictor=predictor, - updater=updater, - hypothesiser=hypothesiser, - data_associator=data_associator, - initiator=initiator, - deleter=deleter, - tracker=tracker) - return {"small_nodes": nodes, "big_nodes": big_nodes, "ground_truths": ground_truths, "lat_nodes": lat_nodes, - "start": start} - - -def test_info_architecture_propagation(params): - """Is information correctly propagated through the architecture?""" - nodes = params['small_nodes'] - ground_truths = params['ground_truths'] - lat_nodes = params['lat_nodes'] - start = params['start'] - - # A "Y" shape, with data travelling "up" the Y - # First, with no latency - edges = Edges([Edge((nodes[0], nodes[1])), Edge((nodes[1], nodes[2])), - Edge((nodes[1], nodes[3]))]) - architecture = InformationArchitecture(edges=edges, current_time=start) - - for _ in range(11): - architecture.measure(ground_truths, noise=True) - architecture.propagate(time_increment=1.0, failed_edges=[]) - architecture.propagate(time_increment=5.0, failed_edges=[]) - architecture.propagate(time_increment=5.0, failed_edges=[]) - architecture.propagate(time_increment=5.0, failed_edges=[]) - # print(f"length of data_held: {len(nodes[0].data_held['unprocessed'][architecture.current_time])}") - # Check all nodes hold 10 times with data pertaining, all in "unprocessed" - # print(nodes[0].data_held['unprocessed'].keys()) - print(f"should be 11 keys in this: {len(nodes[0].data_held['unprocessed'].keys())}") - assert len(nodes[0].data_held['unprocessed']) == 11 - assert len(nodes[0].data_held['processed']) == 0 - # Check each time has exactly 3 pieces of data, one for each target - # print(f"Is this a set? {type(list(nodes[0].data_held['unprocessed'].values())[0])}") - # testin = list(list(nodes[0].data_held['unprocessed'].values())[0]) - # print(len(testin), "\n"*10) - # bleh = testin[0] - # blah = testin[1] - # print(f"Are they the same? {bleh == blah}") - # print(f"Same timestamp? {bleh[1] == blah[1]} and {bleh[0].timestamp == blah[0].timestamp}") - # print(f"Same detection? {bleh[0] == blah[0]}") - # print(bleh) - # print("\n\n\n and \n\n\n") - # print(blah) - # print("\n\n\n") - # blih = set() - # blih.add(bleh) - # blih.add(blah) - # bleeh = {bleh, blah} - # print(f"The length of the set of two equal things is {len(bleeh)}") - for time in nodes[1].data_held['unprocessed']: - print("\n\n\n\n ENTRY \n\n\n\n") - print(len(nodes[1].data_held['unprocessed'][time])) - # print(nodes[0].data_held['unprocessed']) - assert all(len(nodes[0].data_held['unprocessed'][time]) == 1 - for time in nodes[0].data_held['unprocessed']) - # Check node 1 holds 20 messages, and its descendants (2 and 3) hold 30 each - assert len(nodes[1].data_held['unprocessed']) == 11 - assert len(nodes[2].data_held['unprocessed']) == 11 - assert len(nodes[3].data_held['unprocessed']) == 11 - # Check that the data held by node 0 is a subset of that of node 1, and so on for node 1 with - # its own descendants. Note this will only work with zero latency. - assert nodes[0].data_held['unprocessed'].keys() <= nodes[1].data_held['unprocessed'].keys() - assert nodes[1].data_held['unprocessed'].keys() <= nodes[2].data_held['unprocessed'].keys() \ - and nodes[1].data_held['unprocessed'].keys() <= nodes[3].data_held['unprocessed'].keys() - - duplicates = nodes[1].data_held['unprocessed'][list(nodes[1].data_held['unprocessed'].keys())[-1]] - for det in duplicates: - print(hash(det)) - # print(nodes[1].data_held['unprocessed'][list(nodes[1].data_held['unprocessed'].keys())[-1]]) - assert all(len(nodes[1].data_held['unprocessed'][time]) == 2 - for time in nodes[1].data_held['unprocessed']) - for time in nodes[2].data_held['unprocessed']: - print(len(nodes[2].data_held['unprocessed'][time])) - assert all(len(nodes[2].data_held['unprocessed'][time]) == 3 - for time in nodes[2].data_held['unprocessed']) - - # Architecture with latency (Same network as before) - edges_w_latency = Edges( - [Edge((lat_nodes[0], lat_nodes[1]), edge_latency=0.2), Edge((lat_nodes[1], lat_nodes[2]), edge_latency=0.5), - Edge((lat_nodes[1], lat_nodes[3]), edge_latency=0.3465234565634)]) - lat_architecture = InformationArchitecture(edges=edges_w_latency) - for _ in range(10): - lat_architecture.measure(ground_truths, noise=True) - lat_architecture.propagate(time_increment=1, failed_edges=[]) - assert len(nodes[0].data_held) < len(nodes[1].data_held) - # Check node 1 holds fewer messages than both its ancestors - assert len(nodes[1].data_held) < (len(nodes[2].data_held) and len(nodes[3].data_held)) - - # Error Tests - # Test descendants() - with pytest.raises(ValueError): - architecture.descendants(nodes[4]) - - # Test send_message() - with pytest.raises(TypeError): - data = float(1.23456) - the_time_is = datetime.now() - Edge((nodes[0], nodes[4])).send_message(time_sent=the_time_is, data=data) - - # Test update_message() - with pytest.raises(TypeError): - edge1 = Edge((nodes[0], nodes[4])) - data = float(1.23456) - the_time_is = datetime.now() - message = Message(data, nodes[0], nodes[4]) - _, edge1.messages_held = _dict_set(edge1.messages_held, message, 'pending', the_time_is) - edge1.update_messages(the_time_is) - -def test_info_architecture_fusion(params): - """Are Fusion/SensorFusionNodes correctly fusing information together. - (Currently they won't be)""" - nodes = params['small_nodes'] - big_nodes = params['big_nodes'] - ground_truths = params['ground_truths'] - - # A "Y" shape, with data travelling "down" the Y from 2 sensor nodes into one fusion node, and onto a SensorFusion node - edges = Edges([Edge((nodes[0], big_nodes[1])), Edge((nodes[1], big_nodes[1])), Edge((big_nodes[1], big_nodes[0]))]) - architecture = InformationArchitecture(edges=edges) - - for _ in range(10): - architecture.measure(ground_truths, noise=True) - architecture.propagate(time_increment=1, failed_edges=[]) - - assert something #to check data has been fused correctly at big_node[1] and big_node[0] - - - -def test_information_architecture_using_hmm(): - """Heavily inspired by the example: "Classifying Using HMM""" - - # Skip to line 89 for network architectures (rest is from hmm example) - - transition_matrix = np.array([[0.8, 0.2], # P(bike | bike), P(bike | car) - [0.4, 0.6]]) # P(car | bike), P(car | car) - category_transition = MarkovianTransitionModel(transition_matrix=transition_matrix) - - start = datetime.now() - - hidden_classes = ['bike', 'car'] - - # Generating ground truth - ground_truths = list() - for i in range(1, 4): # 4 targets - state_vector = np.zeros(2) # create a vector with 2 zeroes - state_vector[ - np.random.choice(2, 1, p=[1 / 2, 1 / 2])] = 1 # pick a random class out of the 2 - ground_truth_state = CategoricalGroundTruthState(state_vector, - timestamp=start, - categories=hidden_classes) - - ground_truth = GroundTruthPath([ground_truth_state], id=f"GT{i}") - - for _ in range(10): - new_vector = category_transition.function(ground_truth[-1], - noise=True, - time_interval=timedelta(seconds=1)) - new_state = CategoricalGroundTruthState( - new_vector, - timestamp=ground_truth[-1].timestamp + timedelta(seconds=1), - categories=hidden_classes - ) - - ground_truth.append(new_state) - ground_truths.append(ground_truth) - - E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) - [0.19, 0.3], # P(medium | bike), P(medium | car) - [0.01, 0.6]]) # P(large | bike), P(large | car) - model = MarkovianMeasurementModel(emission_matrix=E, - measurement_categories=['small', 'medium', 'large']) - - hmm_sensor = HMMSensor(measurement_model=model) - - transition_matrix = np.array([[0.81, 0.19], # P(bike | bike), P(bike | car) - [0.39, 0.61]]) # P(car | bike), P(car | car) - category_transition = MarkovianTransitionModel(transition_matrix=transition_matrix) - - predictor = HMMPredictor(category_transition) - - updater = HMMUpdater() - - hypothesiser = HMMHypothesiser(predictor=predictor, updater=updater) - - data_associator = GNNWith2DAssignment(hypothesiser) - - prior = CategoricalState([1 / 2, 1 / 2], categories=hidden_classes) - - initiator = SimpleCategoricalMeasurementInitiator(prior_state=prior, updater=updater) - - deleter = UpdateTimeStepsDeleter(2) - - # START HERE FOR THE GOOD STUFF - - hmm_sensor_node_A = SensorNode(sensor=hmm_sensor) - hmm_sensor_processing_node_B = SensorProcessingNode(sensor=hmm_sensor, predictor=predictor, - updater=updater, hypothesiser=hypothesiser, - data_associator=data_associator, - initiator=initiator, deleter=deleter) - info_architecture = InformationArchitecture( - edge_list=[(hmm_sensor_node_A, hmm_sensor_processing_node_B)], - current_time=start) - - for _ in range(10): - # Lots going on inside these two - # Ctrl + click to jump to source code for a class or function :) - - # Gets all SensorNodes (as SensorProcessingNodes inherit from SensorNodes, this is - # both the Nodes in this example) to measure - info_architecture.measure(ground_truths, noise=True) - # The data is propagated through the network, ie our SensorNode sends its measurements to - # the SensorProcessingNode. - info_architecture.propagate(time_increment=1) - - # OK, so this runs up to here, but something has gone wrong - tracks = hmm_sensor_processing_node_B.tracks - print(len(tracks)) - print(hmm_sensor_processing_node_B.data_held) - - # There is data, but no tracks... - - def plot(path, style): - times = list() - probs = list() - for state in path: - times.append(state.timestamp) - probs.append(state.state_vector[0]) - plt.plot(times, probs, linestyle=style) - - # Node B is the 'parent' node, so we want its tracks. Also the only ProcessingNode - # in this example - - for truth in ground_truths: - plot(truth, '--') - for track in tracks: - plot(track, '-') - - plt.show; # - - - -def test_architecture(): - a, b, c, d, e = RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode(), \ - RepeaterNode() - - edge_list_unconnected = [(a, b), (c, d)] - with pytest.raises(ValueError): - Architecture(edge_list=edge_list_unconnected, force_connected=True) - edge_list_connected = [(a, b), (b, c), (b, d)] - a_test_hier = Architecture(edge_list=edge_list_connected, - force_connected=False, name="bleh") - edge_list_loop = [(a, b), (b, c), (c, a)] - a_test_loop = Architecture(edge_list=edge_list_loop, - force_connected=False) - - assert a_test_loop.is_connected and a_test_hier.is_connected - assert a_test_hier.is_hierarchical - assert not a_test_loop.is_hierarchical - - with pytest.raises(TypeError): - a_test_hier.plot(dir_path='U:\\My Documents\\temp', plot_title=True, use_positions=True) - - a_pos, b_pos, c_pos = RepeaterNode(label="Alpha", position=(1, 2)), \ - SensorNode(sensor=PlatformMountable(), position=(1, 1)), \ - RepeaterNode(position=(2, 1)) - edge_list_pos = [(a_pos, b_pos), (b_pos, c_pos), (c_pos, a_pos)] - pos_test = NetworkArchitecture(edge_list_pos) - pos_test.plot(dir_path='C:\\Users\\orosoman\\Desktop\\arch_plots', plot_title=True, - use_positions=True) - - -def test_information_architecture(): - with pytest.raises(TypeError): - # Repeater nodes have no place in an information architecture - InformationArchitecture(edge_list=[(RepeaterNode(), RepeaterNode())]) - ia_test = InformationArchitecture() - - -def test_density(): - a, b, c, d = RepeaterNode(), RepeaterNode(), RepeaterNode(), RepeaterNode() - edge_list = [(a, b), (c, d), (d, a)] - assert Architecture(edge_list=edge_list, node_set={a, b, c, d}).density == 1/2 - - From 65946497d9646ef61a5224fe800b1cd74f5af72b Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Tue, 22 Aug 2023 16:02:41 +0100 Subject: [PATCH 052/170] Add thread for tracker for FusionNode --- stonesoup/architecture/__init__.py | 7 ++-- stonesoup/architecture/edge.py | 14 ++----- stonesoup/architecture/node.py | 67 ++++++++++++++++++++---------- 3 files changed, 53 insertions(+), 35 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 1a9fca0ad..5d528cec4 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -358,7 +358,7 @@ def __init__(self, *args, **kwargs): if isinstance(node, RepeaterNode): raise TypeError("Information architecture should not contain any repeater nodes") for fusion_node in self.fusion_nodes: - fusion_node.tracker.set_time(self.current_time) + pass # fusion_node.tracker.set_time(self.current_time) def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: @@ -415,12 +415,11 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): return for fuse_node in self.fusion_nodes: - x = threading.Thread(target=fuse_node.fuse) - x.start() + fuse_node.fuse() self.current_time += timedelta(seconds=time_increment) for fusion_node in self.fusion_nodes: - fusion_node.tracker.set_time(self.current_time) + pass # fusion_node.tracker.set_time(self.current_time) class NetworkArchitecture(Architecture): diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 5ae947bc8..33da81356 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -17,22 +17,16 @@ class FusionQueue(Queue): - """A queue from which fusion nodes draw data they have yet to fuse""" - def __init__(self): - super().__init__(maxsize=999999) + """A queue from which fusion nodes draw data they have yet to fuse - def get_message(self): - value = self.get() - return value - - def set_message(self, value): - self.put(value) + Iterable, where it blocks attempting to yield items on the queue + """ def __iter__(self): return self def __next__(self): - return self.get_message() + return self.get() class DataPiece(Base): diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 2de551a99..7081bfe36 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -1,3 +1,8 @@ +import threading +from datetime import datetime +from queue import Empty +from typing import Tuple + from ..base import Property, Base from ..sensor.sensor import Sensor from ..types.detection import Detection @@ -5,9 +10,7 @@ from ..types.track import Track from ..tracker.base import Tracker from .edge import DataPiece, FusionQueue -from ..tracker.fusion import SimpleFusionTracker -from datetime import datetime -from typing import Tuple +from ..tracker.fusion import FusionTracker from .functions import _dict_set @@ -53,8 +56,8 @@ def update(self, time_pertaining, time_arrived, data_piece, category, track=None new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, time_arrived, track) added, self.data_held[category] = _dict_set(self.data_held[category], new_data_piece, time_pertaining) - if isinstance(self, FusionNode): - self.fusion_queue.set_message(new_data_piece) + if isinstance(self, FusionNode) and category in ("created", "unfused"): + self.fusion_queue.put((time_pertaining, {data_piece.data})) return added @@ -76,14 +79,11 @@ class SensorNode(Node): class FusionNode(Node): """A node that does not measure new data, but does process data it receives""" # feeder probably as well - tracker: Tracker = Property( + tracker: FusionTracker = Property( doc="Tracker used by this Node to fuse together Tracks and Detections") fusion_queue: FusionQueue = Property( default=FusionQueue(), doc="The queue from which this node draws data to be fused") - track_fusion_tracker: Tracker = Property( - default=None, #SimpleFusionTracker(), - doc="Tracker for associating tracks at the node") tracks: set = Property(default=None, doc="Set of tracks tracked by the fusion node") colour: str = Property( @@ -100,21 +100,46 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.tracks = set() # Set of tracks this Node has recorded + self._track_queue = FusionQueue() + self._tracking_thread = threading.Thread( + target=self._track_thread, + args=(self.tracker, self.fusion_queue, self._track_queue), + daemon=True) + + def update(self, *args, **kwargs): + added = super().update(*args, **kwargs) + if not self._tracking_thread.is_alive(): + try: + self._tracking_thread.start() + except RuntimeError: + pass # Previously started + return added + def fuse(self): - print("A node be fusin") - # we have a queue. - data = self.fusion_queue.get_message() + data = None + timeout = self.latency + while True: + try: + data = self._track_queue.get(timeout=timeout) + except Empty: + break + timeout = 0.1 + # track it + time, tracks = data + self.tracks.update(tracks) - # Sort detections and tracks and group by time + for track in tracks: + data_piece = DataPiece(self, self, track, time, True) + added, self.data_held['fused'] = _dict_set(self.data_held['fused'], data_piece, time) - if data: - # track it - print("there's data") - for time, track in self.tracker: - self.tracks.update(track) - else: - print("no data") - return + if data is None or self.fusion_queue.unfinished_tasks: + print(f"{self.label}: {self.fusion_queue.unfinished_tasks} still being processed") + + @staticmethod + def _track_thread(tracker, input_queue, output_queue): + for time, tracks in tracker: + output_queue.put((time, tracks)) + input_queue.task_done() class SensorFusionNode(SensorNode, FusionNode): From d22f2fb4f7bc6b00fd25d4ed0cf37f4f9f90372d Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 23 Aug 2023 09:43:14 +0100 Subject: [PATCH 053/170] Add plot_style parameter to Architecture plot function to allow hierarchical plotting for non-hierarchical graphs --- stonesoup/architecture/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 5d528cec4..a206b3e13 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -175,7 +175,7 @@ def fusion_nodes(self): return processing def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, - bgcolour="lightgray", node_style="filled", show_plot=True): + bgcolour="lightgray", node_style="filled", produce_plot=True, plot_style=None): """Creates a pdf plot of the directed graph and displays it :param dir_path: The path to save the pdf and .gv files to @@ -189,7 +189,11 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, One alternative is "white" :param node_style: String containing the node style for the plot. Default is "filled". See graphviz attributes for more information. - One alternative is "solid" + One alternative is "solid". + :param produce_plot: Boolean set to true by default. Setting to False prevents the plot from + being displayed. + :param plot_style: String providing a style to be used to plot the graph. Currently only + one option for plot style given by plot_style = 'hierarchical'. :return: """ if use_positions: @@ -199,7 +203,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, "position, given as a Tuple of length 2") attr = {"pos": f"{node.position[0]},{node.position[1]}!"} self.di_graph.nodes[node].update(attr) - elif self.is_hierarchical: + elif self.is_hierarchical or plot_style == 'hierarchical': # Find top node and assign location top_nodes = self.top_nodes @@ -281,7 +285,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, if not filename: filename = self.name viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') - if show_plot: + if produce_plot: viz_graph.view() @property From d0e3cf97614f048a9a6093d7f73910b7be532a3b Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 23 Aug 2023 11:26:00 +0100 Subject: [PATCH 054/170] Add more tests for architecture.py --- .../architecture/tests/test_architecture.py | 149 ++++++++++++++++-- 1 file changed, 134 insertions(+), 15 deletions(-) diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 220b43ec6..4e6822e3c 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -9,6 +9,7 @@ from ...sensor.categorical import HMMSensor from ...models.measurement.categorical import MarkovianMeasurementModel + @pytest.fixture def fixtures(): E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) @@ -34,29 +35,40 @@ def fixtures(): [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), Edge((node6, node3)), Edge((node7, node6))]) - non_hierarchical_edges = Edges( + centralised_edges = Edges( [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), - Edge((node6, node3)), Edge((node7, node6)), Edge((node7, node1))]) + Edge((node6, node3)), Edge((node7, node6)), Edge((node7, node5)), Edge((node5, node3))]) + + simple_edges = Edges([Edge((node2, node1)), Edge((node3, node1))]) + + linear_edges = Edges([Edge((node1, node2)), Edge((node2, node3)), Edge((node3, node4)), + Edge((node4, node5))]) - edges2 = Edges([Edge((node2, node1)), Edge((node3, node1))]) + decentralised_edges = Edges( + [Edge((node2, node1)), Edge((node3, node1)), Edge((node3, node4)), Edge((node3, node5)), + Edge((node5, node4))]) + + disconnected_edges = Edges([Edge((node2, node1)), Edge((node4, node3))]) fixtures = dict() fixtures["hierarchical_edges"] = hierarchical_edges - fixtures["non_hierarchical_edges"] = non_hierarchical_edges - fixtures["Nodes"] = nodes - fixtures["Edges2"] = edges2 - + fixtures["centralised_edges"] = centralised_edges + fixtures["decentralised_edges"] = decentralised_edges + fixtures["nodes"] = nodes + fixtures["simple_edges"] = simple_edges + fixtures["linear_edges"] = linear_edges + fixtures["disconnected_edges"] = disconnected_edges return fixtures def test_hierarchical_plot(tmpdir, fixtures): - nodes = fixtures["Nodes"] + nodes = fixtures["nodes"] edges = fixtures["hierarchical_edges"] arch = InformationArchitecture(edges=edges) - arch.plot(dir_path=tmpdir.join('test.pdf'), show_plot=False) + arch.plot(dir_path=tmpdir.join('test.pdf'), produce_plot=False) assert nodes[0].position[1] == 0 assert nodes[1].position[1] == -1 @@ -76,13 +88,120 @@ def test_density(fixtures): def test_is_hierarchical(fixtures): - h_edges = fixtures["hierarchical_edges"] - n_h_edges = fixtures["non_hierarchical_edges"] + simple_edges = fixtures["simple_edges"] + hierarchical_edges = fixtures["hierarchical_edges"] + centralised_edges = fixtures["centralised_edges"] + linear_edges = fixtures["linear_edges"] + decentralised_edges = fixtures["decentralised_edges"] + disconnected_edges = fixtures["disconnected_edges"] + + # Simple architecture should be hierarchical + simple_architecture = InformationArchitecture(edges=simple_edges) + assert simple_architecture.is_hierarchical + + # Hierarchical architecture should be hierarchical + hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) + assert hierarchical_architecture.is_hierarchical + + # Centralised architecture should not be hierarchical + centralised_architecture = InformationArchitecture(edges=centralised_edges) + assert centralised_architecture.is_hierarchical is False + + # Linear architecture should be hierarchical + linear_architecture = InformationArchitecture(edges=linear_edges) + assert linear_architecture.is_hierarchical + + # Decentralised architecture should not be hierarchical + decentralised_architecture = InformationArchitecture(edges=decentralised_edges) + assert decentralised_architecture.is_hierarchical is False + + # Disconnected architecture should not be connected + disconnected_architecture = InformationArchitecture(edges=disconnected_edges, + force_connected=False) + assert disconnected_architecture.is_hierarchical is False + + +def test_is_centralised(fixtures): + + simple_edges = fixtures["simple_edges"] + hierarchical_edges = fixtures["hierarchical_edges"] + centralised_edges = fixtures["centralised_edges"] + linear_edges = fixtures["linear_edges"] + decentralised_edges = fixtures["decentralised_edges"] + disconnected_edges = fixtures["disconnected_edges"] + + # Simple architecture should be centralised + simple_architecture = InformationArchitecture(edges=simple_edges) + assert simple_architecture.is_centralised + + # Hierarchical architecture should be centralised + hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) + assert hierarchical_architecture.is_centralised + + # Centralised architecture should be centralised + centralised_architecture = InformationArchitecture(edges=centralised_edges) + assert centralised_architecture.is_centralised + + # Decentralised architecture should not be centralised + decentralised_architecture = InformationArchitecture(edges=decentralised_edges) + assert decentralised_architecture.is_centralised is False + + # Linear architecture should be centralised + linear_architecture = InformationArchitecture(edges=linear_edges) + assert linear_architecture.is_centralised + + # Disconnected architecture should not be centralised + disconnected_architecture = InformationArchitecture(edges=disconnected_edges, + force_connected=False) + assert disconnected_architecture.is_centralised is False + + +def test_is_connected(fixtures): + simple_edges = fixtures["simple_edges"] + hierarchical_edges = fixtures["hierarchical_edges"] + centralised_edges = fixtures["centralised_edges"] + linear_edges = fixtures["linear_edges"] + decentralised_edges = fixtures["decentralised_edges"] + disconnected_edges = fixtures["disconnected_edges"] + + # Simple architecture should be connected + simple_architecture = InformationArchitecture(edges=simple_edges) + assert simple_architecture.is_connected + + # Hierarchical architecture should be connected + hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) + assert hierarchical_architecture.is_connected + + # Centralised architecture should be connected + centralised_architecture = InformationArchitecture(edges=centralised_edges) + assert centralised_architecture.is_connected + + # Decentralised architecture should be connected + decentralised_architecture = InformationArchitecture(edges=decentralised_edges) + assert decentralised_architecture.is_connected + + # Linear architecture should be connected + linear_architecture = InformationArchitecture(edges=linear_edges) + assert linear_architecture.is_connected + + # Disconnected architecture should not be connected + disconnected_architecture = InformationArchitecture(edges=disconnected_edges, + force_connected=False) + assert disconnected_architecture.is_connected is False + + +def test_leaf_nodes(fixtures): + nodes = fixtures["nodes"] + simple_edges = fixtures["simple_edges"] + hierarchical_edges = fixtures["hierarchical_edges"] + centralised_edges = fixtures["centralised_edges"] + linear_edges = fixtures["linear_edges"] + decentralised_edges = fixtures["decentralised_edges"] + disconnected_edges = fixtures["disconnected_edges"] - h_architecture = InformationArchitecture(edges=h_edges) - n_h_architecture = InformationArchitecture(edges=n_h_edges) + # Simple architecture should be connected + simple_architecture = InformationArchitecture(edges=simple_edges) + assert simple_architecture.leaf_nodes == set([nodes[1], nodes[2]]) - assert h_architecture.is_hierarchical - assert n_h_architecture.is_hierarchical is False From 1bfb83ab5ad98a837a50ba918fa84c4b18cf9788 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Wed, 23 Aug 2023 11:23:39 +0100 Subject: [PATCH 055/170] renaming to sender/recipient terminology in architectures module --- stonesoup/architecture/__init__.py | 130 +++++++++++++---------------- stonesoup/architecture/edge.py | 12 +-- 2 files changed, 63 insertions(+), 79 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index a206b3e13..f48b27c14 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -55,7 +55,7 @@ def __init__(self, *args, **kwargs): raise ValueError("The graph is not connected. Use force_connected=False, " "if you wish to override this requirement") - # Set attributes such as label, colour, shape, etc for each node + # Set attributes such as label, colour, shape, etc. for each node last_letters = {'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', 'RepeaterNode': ''} for node in self.di_graph.nodes: @@ -69,57 +69,56 @@ def __init__(self, *args, **kwargs): "height": f"{node.node_dim[1]}", "fixedsize": True} self.di_graph.nodes[node].update(attr) - def parents(self, node: Node): + def recipients(self, node: Node): """Returns a set of all nodes to which the input node has a direct edge to""" if node not in self.all_nodes: raise ValueError("Node not in this architecture") - parents = set() + recipients = set() for other in self.all_nodes: if (node, other) in self.edges.edge_list: - parents.add(other) - return parents + recipients.add(other) + return recipients - def children(self, node: Node): + def senders(self, node: Node): """Returns a set of all nodes to which the input node has a direct edge from""" if node not in self.all_nodes: raise ValueError("Node not in this architecture") - children = set() + senders = set() for other in self.all_nodes: if (other, node) in self.edges.edge_list: - children.add(other) - return children + senders.add(other) + return senders def sibling_group(self, node: Node): """Returns a set of siblings of the given node. The given node is included in this set.""" siblings = set() - for parent in self.parents(node): - for child in self.children(parent): - siblings.add(child) + for recipient in self.recipients(node): + for sender in self.senders(recipient): + siblings.add(sender) return siblings @property def shortest_path_dict(self): - g = nx.DiGraph() - for edge in self.edges.edge_list: - g.add_edge(edge[0], edge[1]) - path = nx.all_pairs_shortest_path_length(g) + path = nx.all_pairs_shortest_path_length(self.di_graph) dpath = {x[0]: x[1] for x in path} return dpath - def _parent_position(self, node: Node): - """Returns a tuple of (x_coord, y_coord) giving the location of a node's parent""" - parents = self.parents(node) - if len(parents) == 1: - parent = parents.pop() + def _recipient_position(self, node: Node): + """Returns a tuple of (x_coord, y_coord) giving the location of a node's recipient. + If the node has more than one recipient, a ValueError will be raised. """ + recipients = self.recipients(node) + if len(recipients) == 1: + recipient = recipients.pop() else: - raise ValueError("Node has more than one parent") - return parent.position + raise ValueError("Node has more than one recipient") + return recipient.position @property - def top_nodes(self): + def top_level_nodes(self): + """Returns a list of nodes with no recipients""" top_nodes = list() for node in self.all_nodes: - if len(self.parents(node)) == 0: + if len(self.recipients(node)) == 0: # This node must be the top level node top_nodes.append(node) @@ -145,7 +144,7 @@ def number_of_leaves(self, node: Node): def leaf_nodes(self): leaf_nodes = set() for node in self.all_nodes: - if len(self.children(node)) == 0: + if len(self.senders(node)) == 0: # This must be a leaf node leaf_nodes.add(node) return leaf_nodes @@ -206,7 +205,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, elif self.is_hierarchical or plot_style == 'hierarchical': # Find top node and assign location - top_nodes = self.top_nodes + top_nodes = self.top_level_nodes if len(top_nodes) == 1: top_node = top_nodes[0] else: @@ -220,7 +219,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, plotted_nodes = set() plotted_nodes.add(top_node) - # Set of nodes that have been plotted, but need to have parent nodes plotted + # Set of nodes that have been plotted, but need to have recipient nodes plotted layer_nodes = set() layer_nodes.add(top_node) @@ -232,38 +231,38 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, next_layer_nodes = set() # Iterate through nodes on the current layer (nodes that have been plotted but have - # child nodes that are not plotted) + # sender nodes that are not plotted) for layer_node in layer_nodes: - # Find children of the parent node - children = self.children(layer_node) + # Find senders of the recipient node + senders = self.senders(layer_node) - # Find number of leaf nodes that descend from the parent - n_parent_leaves = self.number_of_leaves(layer_node) + # Find number of leaf nodes that are senders to the recipient + n_recipient_leaves = self.number_of_leaves(layer_node) - # Get parent x_loc - parent_x_loc = layer_node.position[0] + # Get recipient x_loc + recipient_x_loc = layer_node.position[0] # Get location of left limit of the range that leaf nodes will be plotted in - l_x_loc = parent_x_loc - n_parent_leaves/2 + l_x_loc = recipient_x_loc - n_recipient_leaves/2 left_limit = l_x_loc - for child in children: - # Calculate x_loc of the child node - x_loc = left_limit + self.number_of_leaves(child)/2 + for sender in senders: + # Calculate x_loc of the sender node + x_loc = left_limit + self.number_of_leaves(sender)/2 # Update the left limit - left_limit += self.number_of_leaves(child) + left_limit += self.number_of_leaves(sender) - # Update the position of the child node - child.position = (x_loc, layer) - attr = {"pos": f"{child.position[0]},{child.position[1]}!"} - self.di_graph.nodes[child].update(attr) + # Update the position of the sender node + sender.position = (x_loc, layer) + attr = {"pos": f"{sender.position[0]},{sender.position[1]}!"} + self.di_graph.nodes[sender].update(attr) - # Add child node to list of nodes to be considered in next iteration, and + # Add sender node to list of nodes to be considered in next iteration, and # to list of nodes that have been plotted - next_layer_nodes.add(child) - plotted_nodes.add(child) + next_layer_nodes.add(sender) + plotted_nodes.add(sender) # Set list of nodes to be considered next iteration layer_nodes = next_layer_nodes @@ -300,34 +299,19 @@ def density(self): @property def is_hierarchical(self): """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" - # if len(list(nx.simple_cycles(self.di_graph))) > 0 or not self.is_connected: - no_parents = 0 - one_parent = 0 - multiple_parents = 0 - for node in self.all_nodes: - if len(self.parents(node)) == 0: - no_parents += 1 - elif len(self.parents(node)) == 1: - one_parent += 1 - elif len(self.parents(node)) > 1: - multiple_parents += 1 - - if multiple_parents == 0 and no_parents == 1: - return True - else: + if not len(self.top_level_nodes) == 1: return False + for node in self.all_nodes: + if node not in self.top_level_nodes and len(self.recipients(node)) != 1: + return False + return True @property def is_centralised(self): - n_parents = 0 for node in self.all_nodes: - if len(node.children) == 0: - n_parents += 1 - - if n_parents == 0: - return True - else: - return False + if len(node.senders) == 0: + return False + return True @property def is_connected(self): @@ -343,7 +327,7 @@ def __len__(self): @property def fully_propagated(self): """Checks if all data for each node have been transferred - to its parents. With zero latency, this should be the case after running propagate""" + to its recipients. With zero latency, this should be the case after running propagate""" for edge in self.edges.edges: if len(edge.unsent_data) != 0: return False @@ -434,9 +418,9 @@ def propagate(self, time_increment: float): # Still have to deal with latency/bandwidth self.current_time += timedelta(seconds=time_increment) for node in self.all_nodes: - for parent in self.parents(node): + for recipient in self.recipients(node): for data in node.data_held: - parent.update(self.current_time, data) + recipient.update(self.current_time, data) class CombinedArchitecture(Base): diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 33da81356..bc79c636f 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -51,7 +51,7 @@ def __init__(self, *args, **kwargs): class Edge(Base): """Comprised of two connected Nodes""" - nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (child, parent") + nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (sender, recipient)") edge_latency: float = Property(doc="The latency stemming from the edge itself, " "and not either of the nodes", default=0) @@ -111,7 +111,7 @@ def ovr_latency(self): @property def unsent_data(self): - """Data held by the child that has not been sent to the parent.""" + """Data held by the sender that has not been sent to the recipient.""" unsent = [] for status in ["fused", "created"]: for time_pertaining in self.sender.data_held[status]: @@ -147,7 +147,7 @@ def get(self, node_pair): @property def edge_list(self): - """Returns a list of tuples in the form (child, parent)""" + """Returns a list of tuples in the form (sender, recipient)""" edge_list = [] for edge in self.edges: edge_list.append(edge.nodes) @@ -159,14 +159,14 @@ def __len__(self): class Message(Base): """A message, containing a piece of information, that gets propagated between two Nodes. - Messages are opened by nodes that are a parent of the node that sent the message""" + Messages are opened by nodes that are a recipient of the node that sent the message""" edge: Edge = Property( doc="The directed edge containing the sender and receiver of the message") time_pertaining: datetime = Property( doc="The latest time for which the data pertains. For a Detection, this would be the time " "of the Detection, or for a Track this is the time of the last State in the Track. " - "Different from time_sent when data is passed on that was not generated by this " - "Node's child") + "Different from time_sent when data is passed on that was not generated by the " + "sender") time_sent: datetime = Property( doc="Time at which the message was sent") data_piece: DataPiece = Property( From f3d75833e07ff8353be78f75af21f874e692a704 Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 23 Aug 2023 11:45:19 +0100 Subject: [PATCH 056/170] Change logic of InformationArchitecture.is_centralised() to use correct definition --- stonesoup/architecture/__init__.py | 17 ++++++++++++----- .../architecture/tests/test_architecture.py | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index f48b27c14..c63f91a52 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -116,11 +116,11 @@ def _recipient_position(self, node: Node): @property def top_level_nodes(self): """Returns a list of nodes with no recipients""" - top_nodes = list() + top_nodes = set() for node in self.all_nodes: if len(self.recipients(node)) == 0: # This node must be the top level node - top_nodes.append(node) + top_nodes.add(node) return top_nodes @@ -207,7 +207,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, # Find top node and assign location top_nodes = self.top_level_nodes if len(top_nodes) == 1: - top_node = top_nodes[0] + top_node = top_nodes.pop() else: raise ValueError("Graph with more than one top level node provided.") @@ -308,8 +308,15 @@ def is_hierarchical(self): @property def is_centralised(self): - for node in self.all_nodes: - if len(node.senders) == 0: + top_nodes = self.top_level_nodes + if len(top_nodes) != 1: + return False + else: + top_node = top_nodes.pop() + for node in self.all_nodes - self.top_level_nodes: + try: + dist = self.shortest_path_dict[node][top_node] + except KeyError: return False return True diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 4e6822e3c..da087a1b8 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -199,9 +199,9 @@ def test_leaf_nodes(fixtures): decentralised_edges = fixtures["decentralised_edges"] disconnected_edges = fixtures["disconnected_edges"] - # Simple architecture should be connected + # Simple architecture should have 2 leaf nodes simple_architecture = InformationArchitecture(edges=simple_edges) - assert simple_architecture.leaf_nodes == set([nodes[1], nodes[2]]) + assert simple_architecture.leaf_nodes == {nodes[1], nodes[2]} From 26cc798d4c5c00207b1cb92951710d2be078cbd4 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Wed, 23 Aug 2023 13:51:00 +0100 Subject: [PATCH 057/170] beginning tests for node and edge class including conftest file --- stonesoup/architecture/edge.py | 5 +++-- stonesoup/architecture/tests/conftest.py | 25 +++++++++++++++++++++++ stonesoup/architecture/tests/test.node.py | 0 stonesoup/architecture/tests/test_edge.py | 18 ++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 stonesoup/architecture/tests/conftest.py create mode 100644 stonesoup/architecture/tests/test.node.py create mode 100644 stonesoup/architecture/tests/test_edge.py diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index bc79c636f..325c19302 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -30,7 +30,8 @@ def __next__(self): class DataPiece(Base): - """A piece of data for use in an architecture. Sent via Messages, and stored in a Node's data_held""" + """A piece of data for use in an architecture. Sent via a :class:`~.Message`, + and stored in a Node's data_held""" node: "Node" = Property( doc="The Node this data piece belongs to") originator: "Node" = Property( @@ -46,7 +47,7 @@ class DataPiece(Base): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.sent_to = set() # all Nodes the data_piece has been sent to, to avoid duplicates + self.sent_to = set() # all Nodes the data_piece has been sent to, to avoid duplicates class Edge(Base): diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py new file mode 100644 index 000000000..180edec7c --- /dev/null +++ b/stonesoup/architecture/tests/conftest.py @@ -0,0 +1,25 @@ +import pytest + +from datetime import datetime + +from ..edge import Edge +from ..node import Node + + +@pytest.fixture +def edges(): + edge_a = Edge(Node(label="edge_a sender"), Node("edge_a_recipient")) + return {'a': edge_a} + + +@pytest.fixture +def nodes(): + node_a = Node(label="node a") + node_b = Node(label="node b") + return {"a": node_a, "b": node_b} + + +@pytest.fixture +def times(): + time_a = datetime.strptime("23/08/2023 13:36:20", "%d/%m/%Y %H:%M:%S") + return {'a': time_a} diff --git a/stonesoup/architecture/tests/test.node.py b/stonesoup/architecture/tests/test.node.py new file mode 100644 index 000000000..e69de29bb diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py new file mode 100644 index 000000000..218b8843c --- /dev/null +++ b/stonesoup/architecture/tests/test_edge.py @@ -0,0 +1,18 @@ +import pytest + +from datetime import datetime + +from ..edge import Edges, DataPiece +from ...types.track import Track + + +def test_data_piece(nodes, times): + with pytest.raises(TypeError): + data_piece_fail = DataPiece() + + data_piece = DataPiece(node=nodes['a'], originator=nodes['a'], + data=Track([]), time_arrived=times['a']) + assert data_piece.sent_to == set() + +def test_bleh(): + print(datetime.now().strftime(format="%d/%m/%Y %H:%M:%S")) From 3bc9ddff1d48f935b3dd4e1b4f1a6dd7bb327c96 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Wed, 23 Aug 2023 14:34:55 +0100 Subject: [PATCH 058/170] Various modifications for track to track fusion --- stonesoup/architecture/__init__.py | 6 ++--- stonesoup/architecture/edge.py | 4 +--- stonesoup/architecture/node.py | 10 +++++--- stonesoup/feeder/__init__.py | 4 ++-- stonesoup/feeder/track.py | 37 +++++++++++++++++------------- 5 files changed, 34 insertions(+), 27 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index c63f91a52..b038c6a5f 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -167,11 +167,11 @@ def sensor_nodes(self): @property def fusion_nodes(self): - processing = set() + fusion = set() for node in self.all_nodes: if isinstance(node, FusionNode): - processing.add(node) - return processing + fusion.add(node) + return fusion def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, bgcolour="lightgray", node_style="filled", produce_plot=True, plot_style=None): diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 325c19302..5915ddc02 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from ..base import Base, Property from ..types.time import TimeRange from ..types.track import Track @@ -52,7 +50,7 @@ def __init__(self, *args, **kwargs): class Edge(Base): """Comprised of two connected Nodes""" - nodes: Tuple[Node, Node] = Property(doc="A pair of nodes in the form (sender, recipient)") + nodes: Tuple["Node", "Node"] = Property(doc="A pair of nodes in the form (sender, recipient)") edge_latency: float = Property(doc="The latency stemming from the edge itself, " "and not either of the nodes", default=0) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 7081bfe36..5541ece26 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -1,3 +1,4 @@ +import copy import threading from datetime import datetime from queue import Empty @@ -117,7 +118,8 @@ def update(self, *args, **kwargs): def fuse(self): data = None - timeout = self.latency + added = False + timeout = self.latency or 0.1 while True: try: data = self._track_queue.get(timeout=timeout) @@ -129,11 +131,13 @@ def fuse(self): self.tracks.update(tracks) for track in tracks: - data_piece = DataPiece(self, self, track, time, True) - added, self.data_held['fused'] = _dict_set(self.data_held['fused'], data_piece, time) + data_piece = DataPiece(self, self, copy.copy(track), time, True) + if tracks: + added, self.data_held['fused'] = _dict_set(self.data_held['fused'], data_piece, time) if data is None or self.fusion_queue.unfinished_tasks: print(f"{self.label}: {self.fusion_queue.unfinished_tasks} still being processed") + return added @staticmethod def _track_thread(tracker, input_queue, output_queue): diff --git a/stonesoup/feeder/__init__.py b/stonesoup/feeder/__init__.py index a8f072897..757ba9e7f 100644 --- a/stonesoup/feeder/__init__.py +++ b/stonesoup/feeder/__init__.py @@ -4,6 +4,6 @@ framework, and feed them into the tracking algorithms. These can then optionally be used to drop detections, deliver detections out of sequence, synchronise out of sequence detections, etc. """ -from .base import Feeder +from .base import Feeder, DetectionFeeder, GroundTruthFeeder -__all__ = ['Feeder'] +__all__ = ['Feeder', 'DetectionFeeder', 'GroundTruthFeeder'] diff --git a/stonesoup/feeder/track.py b/stonesoup/feeder/track.py index a157f4d53..5d052411d 100644 --- a/stonesoup/feeder/track.py +++ b/stonesoup/feeder/track.py @@ -1,13 +1,14 @@ import numpy as np -from stonesoup.types.detection import GaussianDetection -from stonesoup.feeder.base import DetectionFeeder -from stonesoup.models.measurement.linear import LinearGaussian +from . import DetectionFeeder from ..buffered_generator import BufferedGenerator +from ..models.measurement.linear import LinearGaussian +from ..types.detection import GaussianDetection +from ..types.track import Track class Tracks2GaussianDetectionFeeder(DetectionFeeder): - ''' + """ Feeder consumes Track objects and outputs GaussianDetection objects. At each time step, the :attr:`Reader` feeds in a set of live tracks. The feeder takes the most @@ -15,22 +16,26 @@ class Tracks2GaussianDetectionFeeder(DetectionFeeder): :class:`~.GaussianDetection` objects. Each detection is given a :class:`~.LinearGaussian` measurement model whose covariance is equal to the state covariance. The feeder assumes that the tracks are all live, that is each track has a state at the most recent time step. - ''' + """ @BufferedGenerator.generator_method def data_gen(self): for time, tracks in self.reader: detections = set() for track in tracks: - dim = len(track.state.state_vector) - metadata = track.metadata.copy() - metadata['track_id'] = track.id - detections.add( - GaussianDetection.from_state( - track.state, - measurement_model=LinearGaussian( - dim, list(range(dim)), np.asarray(track.covar)), - metadata=metadata, - target_type=GaussianDetection) - ) + if isinstance(track, Track): + dim = len(track.state.state_vector) + metadata = track.metadata.copy() + metadata['track_id'] = track.id + detections.add( + GaussianDetection.from_state( + track.state, + measurement_model=LinearGaussian( + dim, list(range(dim)), np.asarray(track.covar)), + metadata=metadata, + target_type=GaussianDetection) + ) + else: + # Assume it's a detection + detections.add(track) yield time, detections From 7f4fc10c8a51451a4cfac1425155c24d210dbed6 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Wed, 23 Aug 2023 15:12:49 +0100 Subject: [PATCH 059/170] completion of test_edge_init, additions to architectures conftests --- stonesoup/architecture/edge.py | 4 ++-- stonesoup/architecture/tests/conftest.py | 17 +++++++++++--- stonesoup/architecture/tests/test_edge.py | 28 +++++++++++++++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 5915ddc02..536da549d 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -53,7 +53,7 @@ class Edge(Base): nodes: Tuple["Node", "Node"] = Property(doc="A pair of nodes in the form (sender, recipient)") edge_latency: float = Property(doc="The latency stemming from the edge itself, " "and not either of the nodes", - default=0) + default=0.0) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -106,7 +106,7 @@ def recipient(self): @property def ovr_latency(self): """Overall latency including the two Nodes and the edge latency.""" - return self.sender.latency + self.edge_latency + self.recipient.latency + return self.sender.latency + self.edge_latency @property def unsent_data(self): diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 180edec7c..212590e9d 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -2,8 +2,9 @@ from datetime import datetime -from ..edge import Edge +from ..edge import Edge, DataPiece from ..node import Node +from ...types.track import Track @pytest.fixture @@ -19,7 +20,17 @@ def nodes(): return {"a": node_a, "b": node_b} +@pytest.fixture +def data_pieces(times, nodes): + data_piece_a = DataPiece(node=nodes['a'], originator=nodes['a'], + data=Track([]), time_arrived=times['a']) + data_piece_b = DataPiece(node=nodes['a'], originator=nodes['b'], + data=Track([]), time_arrived=times['b']) + return {'a': data_piece_a, 'b': data_piece_b} + + @pytest.fixture def times(): - time_a = datetime.strptime("23/08/2023 13:36:20", "%d/%m/%Y %H:%M:%S") - return {'a': time_a} + time_a = datetime.strptime("23/08/2023 13:36:00", "%d/%m/%Y %H:%M:%S") + time_b = datetime.strptime("23/08/2023 13:37:00", "%d/%m/%Y %H:%M:%S") + return {'a': time_a, 'b': time_b} diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 218b8843c..5076967c4 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -1,8 +1,6 @@ import pytest -from datetime import datetime - -from ..edge import Edges, DataPiece +from ..edge import Edges, Edge, DataPiece from ...types.track import Track @@ -13,6 +11,26 @@ def test_data_piece(nodes, times): data_piece = DataPiece(node=nodes['a'], originator=nodes['a'], data=Track([]), time_arrived=times['a']) assert data_piece.sent_to == set() + assert data_piece.track is None + + +def test_edge_init(nodes, times, data_pieces): + with pytest.raises(TypeError): + edge_fail = Edge() + edge = Edge((nodes['a'], nodes['b'])) + assert edge.edge_latency == 0.0 + assert edge.sender == nodes['a'] + assert edge.recipient == nodes['b'] + assert edge.nodes == (nodes['a'], nodes['b']) + + assert edge.unsent_data == [] + nodes['a'].data_held['fused'][times['a']] = [data_pieces['a'], data_pieces['b']] + assert (data_pieces['a'], times['a']) in edge.unsent_data + assert (data_pieces['b'], times['a']) in edge.unsent_data + assert len(edge.unsent_data) == 2 + + assert edge.ovr_latency == 0.0 + nodes['a'].latency = 1.0 + nodes['b'].latency = 2.0 + assert edge.ovr_latency == 1.0 -def test_bleh(): - print(datetime.now().strftime(format="%d/%m/%Y %H:%M:%S")) From a1be94db1a993e5fcf8eb3fe39ab6cd2f78c92a4 Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 23 Aug 2023 16:28:44 +0100 Subject: [PATCH 060/170] Tests for all plotting and helper functions in stonesoup.architecture.__init__.py --- stonesoup/architecture/__init__.py | 37 +- .../architecture/tests/test_architecture.py | 332 +++++++++++++++++- 2 files changed, 343 insertions(+), 26 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index b038c6a5f..649455dde 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -89,14 +89,6 @@ def senders(self, node: Node): senders.add(other) return senders - def sibling_group(self, node: Node): - """Returns a set of siblings of the given node. The given node is included in this set.""" - siblings = set() - for recipient in self.recipients(node): - for sender in self.senders(recipient): - siblings.add(sender) - return siblings - @property def shortest_path_dict(self): path = nx.all_pairs_shortest_path_length(self.di_graph) @@ -107,7 +99,9 @@ def _recipient_position(self, node: Node): """Returns a tuple of (x_coord, y_coord) giving the location of a node's recipient. If the node has more than one recipient, a ValueError will be raised. """ recipients = self.recipients(node) - if len(recipients) == 1: + if len(recipients) == 0: + raise ValueError("Node has no recipients") + elif len(recipients) == 1: recipient = recipients.pop() else: raise ValueError("Node has more than one recipient") @@ -127,18 +121,19 @@ def top_level_nodes(self): def number_of_leaves(self, node: Node): node_leaves = set() non_leaves = 0 - for leaf_node in self.leaf_nodes: - try: - shortest_path = self.shortest_path_dict[leaf_node][node] - if shortest_path != 0: - node_leaves.add(leaf_node) - except KeyError: - non_leaves += 1 - if len(node_leaves) == 0: + if node in self.leaf_nodes: return 1 else: - return len(node_leaves) + for leaf_node in self.leaf_nodes: + try: + shortest_path = self.shortest_path_dict[leaf_node][node] + if shortest_path != 0: + node_leaves.add(leaf_node) + except KeyError: + non_leaves += 1 + else: + return len(node_leaves) @property def leaf_nodes(self): @@ -174,7 +169,7 @@ def fusion_nodes(self): return fusion def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, - bgcolour="lightgray", node_style="filled", produce_plot=True, plot_style=None): + bgcolour="lightgray", node_style="filled", save_plot=True, plot_style=None): """Creates a pdf plot of the directed graph and displays it :param dir_path: The path to save the pdf and .gv files to @@ -189,7 +184,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, :param node_style: String containing the node style for the plot. Default is "filled". See graphviz attributes for more information. One alternative is "solid". - :param produce_plot: Boolean set to true by default. Setting to False prevents the plot from + :param save_plot: Boolean set to true by default. Setting to False prevents the plot from being displayed. :param plot_style: String providing a style to be used to plot the graph. Currently only one option for plot style given by plot_style = 'hierarchical'. @@ -284,7 +279,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, if not filename: filename = self.name viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') - if produce_plot: + if save_plot: viz_graph.view() @property diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index da087a1b8..9e0ba0847 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -28,8 +28,15 @@ def fixtures(): node5 = SensorNode(sensor=hmm_sensor, label='5') node6 = SensorNode(sensor=hmm_sensor, label='6') node7 = SensorNode(sensor=hmm_sensor, label='7') + node8 = SensorNode(sensor=hmm_sensor, label='8') - nodes = [node1, node2, node3, node4, node5, node6, node7] + nodes = [node1, node2, node3, node4, node5, node6, node7, node8] + + nodep1 = SensorNode(sensor=hmm_sensor, label='p1', position=(0, 0)) + nodep2 = SensorNode(sensor=hmm_sensor, label='p1', position=(-1, -1)) + nodep3 = SensorNode(sensor=hmm_sensor, label='p1', position=(1, -1)) + + pnodes = [nodep1, nodep2, nodep3] hierarchical_edges = Edges( [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), @@ -50,14 +57,29 @@ def fixtures(): disconnected_edges = Edges([Edge((node2, node1)), Edge((node4, node3))]) + k4_edges = Edges( + [Edge((node1, node2)), Edge((node1, node3)), Edge((node1, node4)), Edge((node2, node3)), + Edge((node2, node4)), Edge((node3, node4))]) + + circular_edges = Edges( + [Edge((node1, node2)), Edge((node2, node3)), Edge((node3, node4)), Edge((node4, node5)), + Edge((node5, node1))]) + + disconnected_loop_edges = Edges([Edge((node2, node1)), Edge((node4, node3)), + Edge((node3, node4))]) + fixtures = dict() fixtures["hierarchical_edges"] = hierarchical_edges fixtures["centralised_edges"] = centralised_edges fixtures["decentralised_edges"] = decentralised_edges fixtures["nodes"] = nodes + fixtures["pnodes"] = pnodes fixtures["simple_edges"] = simple_edges fixtures["linear_edges"] = linear_edges fixtures["disconnected_edges"] = disconnected_edges + fixtures["k4_edges"] = k4_edges + fixtures["circular_edges"] = circular_edges + fixtures["disconnected_loop_edges"] = disconnected_loop_edges return fixtures @@ -68,8 +90,11 @@ def test_hierarchical_plot(tmpdir, fixtures): arch = InformationArchitecture(edges=edges) - arch.plot(dir_path=tmpdir.join('test.pdf'), produce_plot=False) + arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False) + # Check that nodes are plotted on the correct layer. x position of each node is can change + # depending on the order that they are iterated though, hence not entirely predictable so no + # assertion on this is made. assert nodes[0].position[1] == 0 assert nodes[1].position[1] == -1 assert nodes[2].position[1] == -1 @@ -78,12 +103,77 @@ def test_hierarchical_plot(tmpdir, fixtures): assert nodes[5].position[1] == -2 assert nodes[6].position[1] == -3 + # Check that type(position) for each node is a tuple. + assert type(nodes[0].position) == tuple + assert type(nodes[1].position) == tuple + assert type(nodes[2].position) == tuple + assert type(nodes[3].position) == tuple + assert type(nodes[4].position) == tuple + assert type(nodes[5].position) == tuple + assert type(nodes[6].position) == tuple + + decentralised_edges = fixtures["decentralised_edges"] + arch = InformationArchitecture(edges=decentralised_edges) + + with pytest.raises(ValueError): + arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_style='hierarchical') + + +def test_plot_title(fixtures, tmpdir): + edges = fixtures["decentralised_edges"] + + arch = InformationArchitecture(edges=edges) + + # Check that plot function runs when plot_title is given as a str. + arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_title="This is the title of " + "my plot") + + # Check that plot function runs when plot_title is True. + arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_title=True) + + # Check that error is raised when plot_title is not a str or a bool. + x = RepeaterNode() + with pytest.raises(ValueError): + arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_title=x) + + +def test_plot_positions(fixtures, tmpdir): + pnodes = fixtures["pnodes"] + edges = Edges([Edge((pnodes[1], pnodes[0])), Edge((pnodes[2], pnodes[0]))]) -def test_density(fixtures): - edges = fixtures["Edges2"] arch = InformationArchitecture(edges=edges) - assert arch.density == 2/3 + arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, use_positions=True) + + # Assert positions are correct after plot() has run + assert pnodes[0].position == (0, 0) + assert pnodes[1].position == (-1, -1) + assert pnodes[2].position == (1, -1) + + # Change plot positions to non tuple values + pnodes[0].position = RepeaterNode() + pnodes[1].position = 'Not a tuple' + pnodes[2].position = ['Definitely', 'not', 'a', 'tuple'] + + edges = Edges([Edge((pnodes[1], pnodes[0])), Edge((pnodes[2], pnodes[0]))]) + + with pytest.raises(TypeError): + arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, use_positions=True) + + +def test_density(fixtures): + + simple_edges = fixtures["simple_edges"] + k4_edges = fixtures["k4_edges"] + + # Graph k3 (complete graph with 3 nodes) has 3 edges + # Simple architecture has 3 nodes and 2 edges: density should be 2/3 + simple_architecture = InformationArchitecture(edges=simple_edges) + assert simple_architecture.density == 2/3 + + # Graph k4 has 6 edges and density 1 + k4_architecture = InformationArchitecture(edges=k4_edges) + assert k4_architecture.density == 1 def test_is_hierarchical(fixtures): @@ -129,6 +219,7 @@ def test_is_centralised(fixtures): linear_edges = fixtures["linear_edges"] decentralised_edges = fixtures["decentralised_edges"] disconnected_edges = fixtures["disconnected_edges"] + disconnected_loop_edges = fixtures["disconnected_loop_edges"] # Simple architecture should be centralised simple_architecture = InformationArchitecture(edges=simple_edges) @@ -155,6 +246,10 @@ def test_is_centralised(fixtures): force_connected=False) assert disconnected_architecture.is_centralised is False + disconnected_loop_architecture = InformationArchitecture(edges=disconnected_loop_edges, + force_connected=False) + assert disconnected_loop_architecture.is_centralised is False + def test_is_connected(fixtures): simple_edges = fixtures["simple_edges"] @@ -190,6 +285,166 @@ def test_is_connected(fixtures): assert disconnected_architecture.is_connected is False +def test_recipients(fixtures): + nodes = fixtures["nodes"] + centralised_edges = fixtures["centralised_edges"] + + centralised_architecture = InformationArchitecture(edges=centralised_edges) + assert centralised_architecture.recipients(nodes[0]) == set() + assert centralised_architecture.recipients(nodes[1]) == {nodes[0]} + assert centralised_architecture.recipients(nodes[2]) == {nodes[0]} + assert centralised_architecture.recipients(nodes[3]) == {nodes[1]} + assert centralised_architecture.recipients(nodes[4]) == {nodes[1], nodes[2]} + assert centralised_architecture.recipients(nodes[5]) == {nodes[2]} + assert centralised_architecture.recipients(nodes[6]) == {nodes[5], nodes[4]} + + with pytest.raises(ValueError): + centralised_architecture.recipients(nodes[7]) + + +def test_senders(fixtures): + nodes = fixtures["nodes"] + centralised_edges = fixtures["centralised_edges"] + + centralised_architecture = InformationArchitecture(edges=centralised_edges) + assert centralised_architecture.senders(nodes[0]) == {nodes[1], nodes[2]} + assert centralised_architecture.senders(nodes[1]) == {nodes[3], nodes[4]} + assert centralised_architecture.senders(nodes[2]) == {nodes[4], nodes[5]} + assert centralised_architecture.senders(nodes[3]) == set() + assert centralised_architecture.senders(nodes[4]) == {nodes[6]} + assert centralised_architecture.senders(nodes[5]) == {nodes[6]} + assert centralised_architecture.senders(nodes[6]) == set() + + with pytest.raises(ValueError): + centralised_architecture.senders(nodes[7]) + + +def test_shortest_path_dict(fixtures): + + hierarchical_edges = fixtures["hierarchical_edges"] + disconnected_edges = fixtures["disconnected_edges"] + nodes = fixtures["nodes"] + + h_arch = InformationArchitecture(edges=hierarchical_edges) + + assert h_arch.shortest_path_dict[nodes[6]][nodes[5]] == 1 + assert h_arch.shortest_path_dict[nodes[6]][nodes[2]] == 2 + assert h_arch.shortest_path_dict[nodes[6]][nodes[0]] == 3 + assert h_arch.shortest_path_dict[nodes[6]][nodes[6]] == 0 + assert h_arch.shortest_path_dict[nodes[4]][nodes[1]] == 1 + assert h_arch.shortest_path_dict[nodes[4]][nodes[0]] == 2 + + with pytest.raises(KeyError): + dist = h_arch.shortest_path_dict[nodes[1]][nodes[2]] + + with pytest.raises(KeyError): + dist = h_arch.shortest_path_dict[nodes[2]][nodes[5]] + + disconnected_arch = InformationArchitecture(edges=disconnected_edges, force_connected=False) + + assert disconnected_arch.shortest_path_dict[nodes[1]][nodes[0]] == 1 + assert disconnected_arch.shortest_path_dict[nodes[3]][nodes[2]] == 1 + + with pytest.raises(KeyError): + _ = disconnected_arch.shortest_path_dict[nodes[0]][nodes[3]] + _ = disconnected_arch.shortest_path_dict[nodes[2]][nodes[3]] + + +def test_recipient_position(fixtures, tmpdir): + + nodes = fixtures["nodes"] + centralised_edges = fixtures["centralised_edges"] + + centralised_arch = InformationArchitecture(edges=centralised_edges) + centralised_arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, + plot_style='hierarchical') + + # Check types of _recipient_position output is correct + assert type(centralised_arch._recipient_position(nodes[3])) == tuple + assert type(centralised_arch._recipient_position(nodes[3])[0]) == float + assert type(centralised_arch._recipient_position(nodes[3])[1]) == int + + # Check that finding the position of the recipient node of a node with no recipient raises an + # error + with pytest.raises(ValueError): + centralised_arch._recipient_position(nodes[0]) + + # Check that calling _recipient_position on a node with multiple recipients raises an error + with pytest.raises(ValueError): + centralised_arch._recipient_position(nodes[6]) + + +def test_top_level_nodes(fixtures): + nodes = fixtures["nodes"] + simple_edges = fixtures["simple_edges"] + hierarchical_edges = fixtures["hierarchical_edges"] + centralised_edges = fixtures["centralised_edges"] + linear_edges = fixtures["linear_edges"] + decentralised_edges = fixtures["decentralised_edges"] + disconnected_edges = fixtures["disconnected_edges"] + circular_edges = fixtures["circular_edges"] + disconnected_loop_edges = fixtures["disconnected_loop_edges"] + + # Simple architecture 1 top node + simple_architecture = InformationArchitecture(edges=simple_edges) + assert simple_architecture.top_level_nodes == {nodes[0]} + + # Hierarchical architecture should have 1 top node + hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) + assert hierarchical_architecture.top_level_nodes == {nodes[0]} + + # Centralised architecture should have 1 top node + centralised_architecture = InformationArchitecture(edges=centralised_edges) + assert centralised_architecture.top_level_nodes == {nodes[0]} + + # Decentralised architecture should have 2 top nodes + decentralised_architecture = InformationArchitecture(edges=decentralised_edges) + assert decentralised_architecture.top_level_nodes == {nodes[0], nodes[3]} + + # Linear architecture should have 1 top node + linear_architecture = InformationArchitecture(edges=linear_edges) + assert linear_architecture.top_level_nodes == {nodes[4]} + + # Disconnected architecture should have 2 top nodes + disconnected_architecture = InformationArchitecture(edges=disconnected_edges, + force_connected=False) + assert disconnected_architecture.top_level_nodes == {nodes[0], nodes[2]} + + # Circular architecture should have no top node + circular_architecture = InformationArchitecture(edges=circular_edges) + assert circular_architecture.top_level_nodes == set() + + disconnected_loop_architecture = InformationArchitecture(edges=disconnected_loop_edges, + force_connected=False) + assert disconnected_loop_architecture.top_level_nodes == {nodes[0]} + + +def test_number_of_leaves(fixtures): + + hierarchical_edges = fixtures["hierarchical_edges"] + circular_edges = fixtures["circular_edges"] + nodes = fixtures["nodes"] + + hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) + + # Check number of leaves for top node and senders of top node + assert hierarchical_architecture.number_of_leaves(nodes[0]) == 3 + assert hierarchical_architecture.number_of_leaves(nodes[1]) == 2 + assert hierarchical_architecture.number_of_leaves(nodes[2]) == 1 + + # Check number of leafs of a leaf node is 1 despite having no senders + assert hierarchical_architecture.number_of_leaves(nodes[6]) == 1 + + circular_architecture = InformationArchitecture(edges=circular_edges) + + # Check any node in a circular architecture has no leaves + assert circular_architecture.number_of_leaves(nodes[0]) == 0 + assert circular_architecture.number_of_leaves(nodes[1]) == 0 + assert circular_architecture.number_of_leaves(nodes[2]) == 0 + assert circular_architecture.number_of_leaves(nodes[3]) == 0 + assert circular_architecture.number_of_leaves(nodes[4]) == 0 + + def test_leaf_nodes(fixtures): nodes = fixtures["nodes"] simple_edges = fixtures["simple_edges"] @@ -198,10 +453,77 @@ def test_leaf_nodes(fixtures): linear_edges = fixtures["linear_edges"] decentralised_edges = fixtures["decentralised_edges"] disconnected_edges = fixtures["disconnected_edges"] + circular_edges = fixtures["circular_edges"] # Simple architecture should have 2 leaf nodes simple_architecture = InformationArchitecture(edges=simple_edges) assert simple_architecture.leaf_nodes == {nodes[1], nodes[2]} + # Hierarchical architecture should have 3 leaf nodes + hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) + assert hierarchical_architecture.leaf_nodes == {nodes[3], nodes[4], nodes[6]} + + # Centralised architecture should have 2 leaf nodes + centralised_architecture = InformationArchitecture(edges=centralised_edges) + assert centralised_architecture.leaf_nodes == {nodes[3], nodes[6]} + + # Decentralised architecture should have 2 leaf nodes + decentralised_architecture = InformationArchitecture(edges=decentralised_edges) + assert decentralised_architecture.leaf_nodes == {nodes[2], nodes[1]} + + # Linear architecture should have 1 leaf node + linear_architecture = InformationArchitecture(edges=linear_edges) + assert linear_architecture.leaf_nodes == {nodes[0]} + + # Disconnected architecture should have 2 leaf nodes + disconnected_architecture = InformationArchitecture(edges=disconnected_edges, + force_connected=False) + assert disconnected_architecture.leaf_nodes == {nodes[1], nodes[3]} + + # Circular architecture should have no leaf nodes + circular_architecture = InformationArchitecture(edges=circular_edges) + assert circular_architecture.top_level_nodes == set() + + +def test_all_nodes(fixtures): + nodes = fixtures["nodes"] + simple_edges = fixtures["simple_edges"] + hierarchical_edges = fixtures["hierarchical_edges"] + centralised_edges = fixtures["centralised_edges"] + linear_edges = fixtures["linear_edges"] + decentralised_edges = fixtures["decentralised_edges"] + disconnected_edges = fixtures["disconnected_edges"] + circular_edges = fixtures["circular_edges"] + + # Simple architecture should have 3 nodes + simple_architecture = InformationArchitecture(edges=simple_edges) + assert simple_architecture.all_nodes == {nodes[0], nodes[1], nodes[2]} + + # Hierarchical architecture should have 7 nodes + hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) + assert hierarchical_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], + nodes[4], nodes[5], nodes[6]} + + # Centralised architecture should have 7 nodes + centralised_architecture = InformationArchitecture(edges=centralised_edges) + assert centralised_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], + nodes[4], nodes[5], nodes[6]} + + # Decentralised architecture should have 5 nodes + decentralised_architecture = InformationArchitecture(edges=decentralised_edges) + assert decentralised_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], + nodes[4]} + + # Linear architecture should have 5 nodes + linear_architecture = InformationArchitecture(edges=linear_edges) + assert linear_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], nodes[4]} + + # Disconnected architecture should have 4 nodes + disconnected_architecture = InformationArchitecture(edges=disconnected_edges, + force_connected=False) + assert disconnected_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3]} + # Circular architecture should have 4 nodes + circular_architecture = InformationArchitecture(edges=circular_edges) + assert circular_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], nodes[4]} From 063a4bcd51331cc9c0c9d0c94b2cadfbaf39ef19 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Wed, 23 Aug 2023 16:30:36 +0100 Subject: [PATCH 061/170] Fix issue with duplicating tracks in architectures --- stonesoup/architecture/node.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 5541ece26..0b9589d70 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -120,6 +120,7 @@ def fuse(self): data = None added = False timeout = self.latency or 0.1 + updated_tracks = set() while True: try: data = self._track_queue.get(timeout=timeout) @@ -129,14 +130,15 @@ def fuse(self): # track it time, tracks = data self.tracks.update(tracks) - - for track in tracks: - data_piece = DataPiece(self, self, copy.copy(track), time, True) - if tracks: - added, self.data_held['fused'] = _dict_set(self.data_held['fused'], data_piece, time) + updated_tracks |= tracks if data is None or self.fusion_queue.unfinished_tasks: print(f"{self.label}: {self.fusion_queue.unfinished_tasks} still being processed") + + for track in updated_tracks: + data_piece = DataPiece(self, self, copy.copy(track), track.timestamp, True) + added, self.data_held['fused'] = _dict_set( + self.data_held['fused'], data_piece, track.timestamp) return added @staticmethod From b8720f461f67584e0111461e44e73709c5fdbaa1 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Thu, 24 Aug 2023 09:14:23 +0100 Subject: [PATCH 062/170] add to architectures test_edge --- stonesoup/architecture/tests/conftest.py | 2 +- stonesoup/architecture/tests/test_edge.py | 31 ++++++++++++++++++- .../tests/{test.node.py => test_node.py} | 0 3 files changed, 31 insertions(+), 2 deletions(-) rename stonesoup/architecture/tests/{test.node.py => test_node.py} (100%) diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 212590e9d..4379c52b2 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -9,7 +9,7 @@ @pytest.fixture def edges(): - edge_a = Edge(Node(label="edge_a sender"), Node("edge_a_recipient")) + edge_a = Edge((Node(label="edge_a sender"), Node(label="edge_a_recipient"))) return {'a': edge_a} diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 5076967c4..1c7807f7f 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -1,6 +1,6 @@ import pytest -from ..edge import Edges, Edge, DataPiece +from ..edge import Edges, Edge, DataPiece, Message from ...types.track import Track @@ -22,6 +22,7 @@ def test_edge_init(nodes, times, data_pieces): assert edge.sender == nodes['a'] assert edge.recipient == nodes['b'] assert edge.nodes == (nodes['a'], nodes['b']) + assert all(len(edge.messages_held[status]) == 0 for status in ['pending', 'received']) assert edge.unsent_data == [] nodes['a'].data_held['fused'][times['a']] = [data_pieces['a'], data_pieces['b']] @@ -34,3 +35,31 @@ def test_edge_init(nodes, times, data_pieces): nodes['b'].latency = 2.0 assert edge.ovr_latency == 1.0 + +def test_send_update_message(edges, times, data_pieces): + edge = edges['a'] + assert len(edge.messages_held['pending']) == 0 + + message = Message(edge, times['a'], times['a'], data_pieces['a']) + edge.send_message(data_pieces['a'], times['a'], times['a']) + + assert len(edge.messages_held['pending']) == 1 + assert times['a'] in edge.messages_held['pending'] + assert len(edge.messages_held['pending'][times['a']]) == 1 + print("\n\n\n") + print(message) + print("\n\n\n") + print(edge.messages_held['pending'][times['a']]) + print(message) + assert message in edge.messages_held['pending'][times['a']] + assert len(edge.messages_held['received']) == 0 + # times_b is 1 min later + edge.update_messages(current_time=times['b']) + + assert len(edge.messages_held['received']) == 1 + assert len(edge.messages_held['pending']) == 0 + #assert message in edge.messages_held['received'][times['a']] + + +def test_failed(): + assert True \ No newline at end of file diff --git a/stonesoup/architecture/tests/test.node.py b/stonesoup/architecture/tests/test_node.py similarity index 100% rename from stonesoup/architecture/tests/test.node.py rename to stonesoup/architecture/tests/test_node.py From bfaae80859185f4e20202985c1b439d11c127313 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Thu, 24 Aug 2023 09:48:49 +0100 Subject: [PATCH 063/170] minor test_edge changes --- stonesoup/architecture/edge.py | 10 ++++++++++ stonesoup/architecture/tests/test_edge.py | 7 +------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 536da549d..e63214856 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -89,6 +89,8 @@ def update_messages(self, current_time): for time, message in to_remove: self.messages_held['pending'][time].remove(message) + if len(self.messages_held['pending'][time]) == 0: + del self.messages_held['pending'][time] def failed(self, current_time, duration): """Keeps track of when this edge was failed using the time_ranges_failed property. """ @@ -198,3 +200,11 @@ def update(self, current_time): self.status = "receiving" else: self.status = "received" + + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + return all(getattr(self, name) == getattr(other, name) for name in type(self).properties) + + def __hash__(self): + return hash(tuple(getattr(self, name) for name in type(self).properties)) diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 1c7807f7f..e6beb2cd0 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -46,11 +46,6 @@ def test_send_update_message(edges, times, data_pieces): assert len(edge.messages_held['pending']) == 1 assert times['a'] in edge.messages_held['pending'] assert len(edge.messages_held['pending'][times['a']]) == 1 - print("\n\n\n") - print(message) - print("\n\n\n") - print(edge.messages_held['pending'][times['a']]) - print(message) assert message in edge.messages_held['pending'][times['a']] assert len(edge.messages_held['received']) == 0 # times_b is 1 min later @@ -58,7 +53,7 @@ def test_send_update_message(edges, times, data_pieces): assert len(edge.messages_held['received']) == 1 assert len(edge.messages_held['pending']) == 0 - #assert message in edge.messages_held['received'][times['a']] + assert message in edge.messages_held['received'][times['a']] def test_failed(): From 7f7cdb8c1a682e125437a128acc0f5b2a80e8e50 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 24 Aug 2023 11:03:26 +0100 Subject: [PATCH 064/170] Move test_architecture.py fixtures to conftest.py and reconfigure test_architecture.py to work with changes --- stonesoup/architecture/tests/conftest.py | 79 +++- .../architecture/tests/test_architecture.py | 419 +++++++----------- 2 files changed, 244 insertions(+), 254 deletions(-) diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 4379c52b2..3b4565fab 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -1,10 +1,13 @@ import pytest from datetime import datetime +import numpy as np -from ..edge import Edge, DataPiece -from ..node import Node +from ..edge import Edge, DataPiece, Edges +from ..node import Node, RepeaterNode, SensorNode, FusionNode, SensorFusionNode from ...types.track import Track +from ...sensor.categorical import HMMSensor +from ...models.measurement.categorical import MarkovianMeasurementModel @pytest.fixture @@ -15,9 +18,79 @@ def edges(): @pytest.fixture def nodes(): + E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) + [0.19, 0.3], # P(medium | bike), P(medium | car) + [0.01, 0.6]]) # P(large | bike), P(large | car) + + model = MarkovianMeasurementModel(emission_matrix=E, + measurement_categories=['small', 'medium', 'large']) + + hmm_sensor = HMMSensor(measurement_model=model) + node_a = Node(label="node a") node_b = Node(label="node b") - return {"a": node_a, "b": node_b} + sensornode_1 = SensorNode(sensor=hmm_sensor, label='s1') + sensornode_2 = SensorNode(sensor=hmm_sensor, label='s2') + sensornode_3 = SensorNode(sensor=hmm_sensor, label='s3') + sensornode_4 = SensorNode(sensor=hmm_sensor, label='s4') + sensornode_5 = SensorNode(sensor=hmm_sensor, label='s5') + sensornode_6 = SensorNode(sensor=hmm_sensor, label='s6') + sensornode_7 = SensorNode(sensor=hmm_sensor, label='s7') + sensornode_8 = SensorNode(sensor=hmm_sensor, label='s8') + pnode_1 = SensorNode(sensor=hmm_sensor, label='p1', position=(0, 0)) + pnode_2 = SensorNode(sensor=hmm_sensor, label='p2', position=(-1, -1)) + pnode_3 = SensorNode(sensor=hmm_sensor, label='p3', position=(1, -1)) + + return {"a": node_a, "b": node_b, "s1": sensornode_1, "s2": sensornode_2, "s3": sensornode_3, + "s4": sensornode_4, "s5": sensornode_5, "s6": sensornode_6, "s7": sensornode_7, + "s8": sensornode_8, "p1": pnode_1, "p2": pnode_2, "p3": pnode_3} + + +@pytest.fixture +def edge_lists(nodes): + hierarchical_edges = Edges([Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s3'], nodes['s1'])), + Edge((nodes['s4'], nodes['s2'])), Edge((nodes['s5'], nodes['s2'])), + Edge((nodes['s6'], nodes['s3'])), Edge((nodes['s7'], nodes['s6']))]) + + centralised_edges = Edges( + [Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s3'], nodes['s1'])), + Edge((nodes['s4'], nodes['s2'])), Edge((nodes['s5'], nodes['s2'])), + Edge((nodes['s6'], nodes['s3'])), Edge((nodes['s7'], nodes['s6'])), + Edge((nodes['s7'], nodes['s5'])), Edge((nodes['s5'], nodes['s3']))]) + + simple_edges = Edges([Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s3'], nodes['s1']))]) + + linear_edges = Edges([Edge((nodes['s1'], nodes['s2'])), Edge((nodes['s2'], nodes['s3'])), + Edge((nodes['s3'], nodes['s4'])), + Edge((nodes['s4'], nodes['s5']))]) + + decentralised_edges = Edges( + [Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s3'], nodes['s1'])), + Edge((nodes['s3'], nodes['s4'])), Edge((nodes['s3'], nodes['s5'])), + Edge((nodes['s5'], nodes['s4']))]) + + disconnected_edges = Edges([Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s4'], nodes['s3']))]) + + k4_edges = Edges( + [Edge((nodes['s1'], nodes['s2'])), Edge((nodes['s1'], nodes['s3'])), + Edge((nodes['s1'], nodes['s4'])), Edge((nodes['s2'], nodes['s3'])), + Edge((nodes['s2'], nodes['s4'])), Edge((nodes['s3'], nodes['s4']))]) + + circular_edges = Edges( + [Edge((nodes['s1'], nodes['s2'])), Edge((nodes['s2'], nodes['s3'])), + Edge((nodes['s3'], nodes['s4'])), Edge((nodes['s4'], nodes['s5'])), + Edge((nodes['s5'], nodes['s1']))]) + + disconnected_loop_edges = Edges( + [Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s4'], nodes['s3'])), + Edge((nodes['s3'], nodes['s4']))]) + + return {"hierarchical_edges": hierarchical_edges, "centralised_edges": centralised_edges, + "simple_edges": simple_edges, "linear_edges": linear_edges, + "decentralised_edges": decentralised_edges, "disconnected_edges": disconnected_edges, + "k4_edges": k4_edges, "circular_edges": circular_edges, + "disconnected_loop_edges": disconnected_loop_edges} + @pytest.fixture diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 9e0ba0847..38a7093bc 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -1,92 +1,13 @@ import pytest -import numpy as np - - from stonesoup.architecture import InformationArchitecture from ..edge import Edge, Edges -from ..node import RepeaterNode, SensorNode -from ...sensor.categorical import HMMSensor -from ...models.measurement.categorical import MarkovianMeasurementModel - - -@pytest.fixture -def fixtures(): - E = np.array([[0.8, 0.1], # P(small | bike), P(small | car) - [0.19, 0.3], # P(medium | bike), P(medium | car) - [0.01, 0.6]]) # P(large | bike), P(large | car) - - model = MarkovianMeasurementModel(emission_matrix=E, - measurement_categories=['small', 'medium', 'large']) - - hmm_sensor = HMMSensor(measurement_model=model) - - node1 = SensorNode(sensor=hmm_sensor, label='1') - node2 = SensorNode(sensor=hmm_sensor, label='2') - node3 = SensorNode(sensor=hmm_sensor, label='3') - node4 = SensorNode(sensor=hmm_sensor, label='4') - node5 = SensorNode(sensor=hmm_sensor, label='5') - node6 = SensorNode(sensor=hmm_sensor, label='6') - node7 = SensorNode(sensor=hmm_sensor, label='7') - node8 = SensorNode(sensor=hmm_sensor, label='8') - - nodes = [node1, node2, node3, node4, node5, node6, node7, node8] - - nodep1 = SensorNode(sensor=hmm_sensor, label='p1', position=(0, 0)) - nodep2 = SensorNode(sensor=hmm_sensor, label='p1', position=(-1, -1)) - nodep3 = SensorNode(sensor=hmm_sensor, label='p1', position=(1, -1)) - - pnodes = [nodep1, nodep2, nodep3] - - hierarchical_edges = Edges( - [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), - Edge((node6, node3)), Edge((node7, node6))]) +from ..node import RepeaterNode - centralised_edges = Edges( - [Edge((node2, node1)), Edge((node3, node1)), Edge((node4, node2)), Edge((node5, node2)), - Edge((node6, node3)), Edge((node7, node6)), Edge((node7, node5)), Edge((node5, node3))]) - simple_edges = Edges([Edge((node2, node1)), Edge((node3, node1))]) +def test_hierarchical_plot(tmpdir, nodes, edge_lists): - linear_edges = Edges([Edge((node1, node2)), Edge((node2, node3)), Edge((node3, node4)), - Edge((node4, node5))]) - - decentralised_edges = Edges( - [Edge((node2, node1)), Edge((node3, node1)), Edge((node3, node4)), Edge((node3, node5)), - Edge((node5, node4))]) - - disconnected_edges = Edges([Edge((node2, node1)), Edge((node4, node3))]) - - k4_edges = Edges( - [Edge((node1, node2)), Edge((node1, node3)), Edge((node1, node4)), Edge((node2, node3)), - Edge((node2, node4)), Edge((node3, node4))]) - - circular_edges = Edges( - [Edge((node1, node2)), Edge((node2, node3)), Edge((node3, node4)), Edge((node4, node5)), - Edge((node5, node1))]) - - disconnected_loop_edges = Edges([Edge((node2, node1)), Edge((node4, node3)), - Edge((node3, node4))]) - - fixtures = dict() - fixtures["hierarchical_edges"] = hierarchical_edges - fixtures["centralised_edges"] = centralised_edges - fixtures["decentralised_edges"] = decentralised_edges - fixtures["nodes"] = nodes - fixtures["pnodes"] = pnodes - fixtures["simple_edges"] = simple_edges - fixtures["linear_edges"] = linear_edges - fixtures["disconnected_edges"] = disconnected_edges - fixtures["k4_edges"] = k4_edges - fixtures["circular_edges"] = circular_edges - fixtures["disconnected_loop_edges"] = disconnected_loop_edges - return fixtures - - -def test_hierarchical_plot(tmpdir, fixtures): - - nodes = fixtures["nodes"] - edges = fixtures["hierarchical_edges"] + edges = edge_lists["hierarchical_edges"] arch = InformationArchitecture(edges=edges) @@ -95,32 +16,32 @@ def test_hierarchical_plot(tmpdir, fixtures): # Check that nodes are plotted on the correct layer. x position of each node is can change # depending on the order that they are iterated though, hence not entirely predictable so no # assertion on this is made. - assert nodes[0].position[1] == 0 - assert nodes[1].position[1] == -1 - assert nodes[2].position[1] == -1 - assert nodes[3].position[1] == -2 - assert nodes[4].position[1] == -2 - assert nodes[5].position[1] == -2 - assert nodes[6].position[1] == -3 + assert nodes['s1'].position[1] == 0 + assert nodes['s2'].position[1] == -1 + assert nodes['s3'].position[1] == -1 + assert nodes['s4'].position[1] == -2 + assert nodes['s5'].position[1] == -2 + assert nodes['s6'].position[1] == -2 + assert nodes['s7'].position[1] == -3 # Check that type(position) for each node is a tuple. - assert type(nodes[0].position) == tuple - assert type(nodes[1].position) == tuple - assert type(nodes[2].position) == tuple - assert type(nodes[3].position) == tuple - assert type(nodes[4].position) == tuple - assert type(nodes[5].position) == tuple - assert type(nodes[6].position) == tuple - - decentralised_edges = fixtures["decentralised_edges"] + assert type(nodes['s1'].position) == tuple + assert type(nodes['s2'].position) == tuple + assert type(nodes['s3'].position) == tuple + assert type(nodes['s4'].position) == tuple + assert type(nodes['s5'].position) == tuple + assert type(nodes['s6'].position) == tuple + assert type(nodes['s7'].position) == tuple + + decentralised_edges = edge_lists["decentralised_edges"] arch = InformationArchitecture(edges=decentralised_edges) with pytest.raises(ValueError): arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_style='hierarchical') -def test_plot_title(fixtures, tmpdir): - edges = fixtures["decentralised_edges"] +def test_plot_title(nodes, tmpdir, edge_lists): + edges = edge_lists["decentralised_edges"] arch = InformationArchitecture(edges=edges) @@ -137,34 +58,34 @@ def test_plot_title(fixtures, tmpdir): arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_title=x) -def test_plot_positions(fixtures, tmpdir): - pnodes = fixtures["pnodes"] - edges = Edges([Edge((pnodes[1], pnodes[0])), Edge((pnodes[2], pnodes[0]))]) +def test_plot_positions(nodes, tmpdir): + edges1 = Edges([Edge((nodes['p2'], nodes['p1'])), Edge((nodes['p3'], nodes['p1']))]) - arch = InformationArchitecture(edges=edges) + arch1 = InformationArchitecture(edges=edges1) - arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, use_positions=True) + arch1.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, use_positions=True) # Assert positions are correct after plot() has run - assert pnodes[0].position == (0, 0) - assert pnodes[1].position == (-1, -1) - assert pnodes[2].position == (1, -1) + assert nodes['p1'].position == (0, 0) + assert nodes['p2'].position == (-1, -1) + assert nodes['p3'].position == (1, -1) # Change plot positions to non tuple values - pnodes[0].position = RepeaterNode() - pnodes[1].position = 'Not a tuple' - pnodes[2].position = ['Definitely', 'not', 'a', 'tuple'] + nodes['p3'].position = RepeaterNode() + nodes['p2'].position = 'Not a tuple' + nodes['p3'].position = ['Definitely', 'not', 'a', 'tuple'] - edges = Edges([Edge((pnodes[1], pnodes[0])), Edge((pnodes[2], pnodes[0]))]) + edges2 = Edges([Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s3'], nodes['s1']))]) + arch2 = InformationArchitecture(edges=edges2) with pytest.raises(TypeError): - arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, use_positions=True) + arch2.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, use_positions=True) -def test_density(fixtures): +def test_density(edge_lists): - simple_edges = fixtures["simple_edges"] - k4_edges = fixtures["k4_edges"] + simple_edges = edge_lists["simple_edges"] + k4_edges = edge_lists["k4_edges"] # Graph k3 (complete graph with 3 nodes) has 3 edges # Simple architecture has 3 nodes and 2 edges: density should be 2/3 @@ -176,14 +97,14 @@ def test_density(fixtures): assert k4_architecture.density == 1 -def test_is_hierarchical(fixtures): +def test_is_hierarchical(edge_lists): - simple_edges = fixtures["simple_edges"] - hierarchical_edges = fixtures["hierarchical_edges"] - centralised_edges = fixtures["centralised_edges"] - linear_edges = fixtures["linear_edges"] - decentralised_edges = fixtures["decentralised_edges"] - disconnected_edges = fixtures["disconnected_edges"] + simple_edges = edge_lists["simple_edges"] + hierarchical_edges = edge_lists["hierarchical_edges"] + centralised_edges = edge_lists["centralised_edges"] + linear_edges = edge_lists["linear_edges"] + decentralised_edges = edge_lists["decentralised_edges"] + disconnected_edges = edge_lists["disconnected_edges"] # Simple architecture should be hierarchical simple_architecture = InformationArchitecture(edges=simple_edges) @@ -211,15 +132,15 @@ def test_is_hierarchical(fixtures): assert disconnected_architecture.is_hierarchical is False -def test_is_centralised(fixtures): +def test_is_centralised(edge_lists): - simple_edges = fixtures["simple_edges"] - hierarchical_edges = fixtures["hierarchical_edges"] - centralised_edges = fixtures["centralised_edges"] - linear_edges = fixtures["linear_edges"] - decentralised_edges = fixtures["decentralised_edges"] - disconnected_edges = fixtures["disconnected_edges"] - disconnected_loop_edges = fixtures["disconnected_loop_edges"] + simple_edges = edge_lists["simple_edges"] + hierarchical_edges = edge_lists["hierarchical_edges"] + centralised_edges = edge_lists["centralised_edges"] + linear_edges = edge_lists["linear_edges"] + decentralised_edges = edge_lists["decentralised_edges"] + disconnected_edges = edge_lists["disconnected_edges"] + disconnected_loop_edges = edge_lists["disconnected_loop_edges"] # Simple architecture should be centralised simple_architecture = InformationArchitecture(edges=simple_edges) @@ -251,13 +172,13 @@ def test_is_centralised(fixtures): assert disconnected_loop_architecture.is_centralised is False -def test_is_connected(fixtures): - simple_edges = fixtures["simple_edges"] - hierarchical_edges = fixtures["hierarchical_edges"] - centralised_edges = fixtures["centralised_edges"] - linear_edges = fixtures["linear_edges"] - decentralised_edges = fixtures["decentralised_edges"] - disconnected_edges = fixtures["disconnected_edges"] +def test_is_connected(edge_lists): + simple_edges = edge_lists["simple_edges"] + hierarchical_edges = edge_lists["hierarchical_edges"] + centralised_edges = edge_lists["centralised_edges"] + linear_edges = edge_lists["linear_edges"] + decentralised_edges = edge_lists["decentralised_edges"] + disconnected_edges = edge_lists["disconnected_edges"] # Simple architecture should be connected simple_architecture = InformationArchitecture(edges=simple_edges) @@ -285,130 +206,125 @@ def test_is_connected(fixtures): assert disconnected_architecture.is_connected is False -def test_recipients(fixtures): - nodes = fixtures["nodes"] - centralised_edges = fixtures["centralised_edges"] +def test_recipients(nodes, edge_lists): + centralised_edges = edge_lists["centralised_edges"] centralised_architecture = InformationArchitecture(edges=centralised_edges) - assert centralised_architecture.recipients(nodes[0]) == set() - assert centralised_architecture.recipients(nodes[1]) == {nodes[0]} - assert centralised_architecture.recipients(nodes[2]) == {nodes[0]} - assert centralised_architecture.recipients(nodes[3]) == {nodes[1]} - assert centralised_architecture.recipients(nodes[4]) == {nodes[1], nodes[2]} - assert centralised_architecture.recipients(nodes[5]) == {nodes[2]} - assert centralised_architecture.recipients(nodes[6]) == {nodes[5], nodes[4]} + assert centralised_architecture.recipients(nodes['s1']) == set() + assert centralised_architecture.recipients(nodes['s2']) == {nodes['s1']} + assert centralised_architecture.recipients(nodes['s3']) == {nodes['s1']} + assert centralised_architecture.recipients(nodes['s4']) == {nodes['s2']} + assert centralised_architecture.recipients(nodes['s5']) == {nodes['s2'], nodes['s3']} + assert centralised_architecture.recipients(nodes['s6']) == {nodes['s3']} + assert centralised_architecture.recipients(nodes['s7']) == {nodes['s6'], nodes['s5']} with pytest.raises(ValueError): - centralised_architecture.recipients(nodes[7]) + centralised_architecture.recipients(nodes['s8']) -def test_senders(fixtures): - nodes = fixtures["nodes"] - centralised_edges = fixtures["centralised_edges"] +def test_senders(nodes, edge_lists): + centralised_edges = edge_lists["centralised_edges"] centralised_architecture = InformationArchitecture(edges=centralised_edges) - assert centralised_architecture.senders(nodes[0]) == {nodes[1], nodes[2]} - assert centralised_architecture.senders(nodes[1]) == {nodes[3], nodes[4]} - assert centralised_architecture.senders(nodes[2]) == {nodes[4], nodes[5]} - assert centralised_architecture.senders(nodes[3]) == set() - assert centralised_architecture.senders(nodes[4]) == {nodes[6]} - assert centralised_architecture.senders(nodes[5]) == {nodes[6]} - assert centralised_architecture.senders(nodes[6]) == set() + assert centralised_architecture.senders(nodes['s1']) == {nodes['s2'], nodes['s3']} + assert centralised_architecture.senders(nodes['s2']) == {nodes['s4'], nodes['s5']} + assert centralised_architecture.senders(nodes['s3']) == {nodes['s5'], nodes['s6']} + assert centralised_architecture.senders(nodes['s4']) == set() + assert centralised_architecture.senders(nodes['s5']) == {nodes['s7']} + assert centralised_architecture.senders(nodes['s6']) == {nodes['s7']} + assert centralised_architecture.senders(nodes['s7']) == set() with pytest.raises(ValueError): - centralised_architecture.senders(nodes[7]) + centralised_architecture.senders(nodes['s8']) -def test_shortest_path_dict(fixtures): +def test_shortest_path_dict(nodes, edge_lists): - hierarchical_edges = fixtures["hierarchical_edges"] - disconnected_edges = fixtures["disconnected_edges"] - nodes = fixtures["nodes"] + hierarchical_edges = edge_lists["hierarchical_edges"] + disconnected_edges = edge_lists["disconnected_edges"] h_arch = InformationArchitecture(edges=hierarchical_edges) - assert h_arch.shortest_path_dict[nodes[6]][nodes[5]] == 1 - assert h_arch.shortest_path_dict[nodes[6]][nodes[2]] == 2 - assert h_arch.shortest_path_dict[nodes[6]][nodes[0]] == 3 - assert h_arch.shortest_path_dict[nodes[6]][nodes[6]] == 0 - assert h_arch.shortest_path_dict[nodes[4]][nodes[1]] == 1 - assert h_arch.shortest_path_dict[nodes[4]][nodes[0]] == 2 + assert h_arch.shortest_path_dict[nodes['s7']][nodes['s6']] == 1 + assert h_arch.shortest_path_dict[nodes['s7']][nodes['s3']] == 2 + assert h_arch.shortest_path_dict[nodes['s7']][nodes['s1']] == 3 + assert h_arch.shortest_path_dict[nodes['s7']][nodes['s7']] == 0 + assert h_arch.shortest_path_dict[nodes['s5']][nodes['s2']] == 1 + assert h_arch.shortest_path_dict[nodes['s5']][nodes['s1']] == 2 with pytest.raises(KeyError): - dist = h_arch.shortest_path_dict[nodes[1]][nodes[2]] + dist = h_arch.shortest_path_dict[nodes['s2']][nodes['s3']] with pytest.raises(KeyError): - dist = h_arch.shortest_path_dict[nodes[2]][nodes[5]] + dist = h_arch.shortest_path_dict[nodes['s3']][nodes['s6']] disconnected_arch = InformationArchitecture(edges=disconnected_edges, force_connected=False) - assert disconnected_arch.shortest_path_dict[nodes[1]][nodes[0]] == 1 - assert disconnected_arch.shortest_path_dict[nodes[3]][nodes[2]] == 1 + assert disconnected_arch.shortest_path_dict[nodes['s2']][nodes['s1']] == 1 + assert disconnected_arch.shortest_path_dict[nodes['s4']][nodes['s3']] == 1 with pytest.raises(KeyError): - _ = disconnected_arch.shortest_path_dict[nodes[0]][nodes[3]] - _ = disconnected_arch.shortest_path_dict[nodes[2]][nodes[3]] + _ = disconnected_arch.shortest_path_dict[nodes['s1']][nodes['s4']] + _ = disconnected_arch.shortest_path_dict[nodes['s3']][nodes['s4']] -def test_recipient_position(fixtures, tmpdir): +def test_recipient_position(nodes, tmpdir, edge_lists): - nodes = fixtures["nodes"] - centralised_edges = fixtures["centralised_edges"] + centralised_edges = edge_lists["centralised_edges"] centralised_arch = InformationArchitecture(edges=centralised_edges) centralised_arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_style='hierarchical') # Check types of _recipient_position output is correct - assert type(centralised_arch._recipient_position(nodes[3])) == tuple - assert type(centralised_arch._recipient_position(nodes[3])[0]) == float - assert type(centralised_arch._recipient_position(nodes[3])[1]) == int + assert type(centralised_arch._recipient_position(nodes['s4'])) == tuple + assert type(centralised_arch._recipient_position(nodes['s4'])[0]) == float + assert type(centralised_arch._recipient_position(nodes['s4'])[1]) == int # Check that finding the position of the recipient node of a node with no recipient raises an # error with pytest.raises(ValueError): - centralised_arch._recipient_position(nodes[0]) + centralised_arch._recipient_position(nodes['s1']) # Check that calling _recipient_position on a node with multiple recipients raises an error with pytest.raises(ValueError): - centralised_arch._recipient_position(nodes[6]) + centralised_arch._recipient_position(nodes['s7']) -def test_top_level_nodes(fixtures): - nodes = fixtures["nodes"] - simple_edges = fixtures["simple_edges"] - hierarchical_edges = fixtures["hierarchical_edges"] - centralised_edges = fixtures["centralised_edges"] - linear_edges = fixtures["linear_edges"] - decentralised_edges = fixtures["decentralised_edges"] - disconnected_edges = fixtures["disconnected_edges"] - circular_edges = fixtures["circular_edges"] - disconnected_loop_edges = fixtures["disconnected_loop_edges"] +def test_top_level_nodes(nodes, edge_lists): + simple_edges = edge_lists["simple_edges"] + hierarchical_edges = edge_lists["hierarchical_edges"] + centralised_edges = edge_lists["centralised_edges"] + linear_edges = edge_lists["linear_edges"] + decentralised_edges = edge_lists["decentralised_edges"] + disconnected_edges = edge_lists["disconnected_edges"] + circular_edges = edge_lists["circular_edges"] + disconnected_loop_edges = edge_lists["disconnected_loop_edges"] # Simple architecture 1 top node simple_architecture = InformationArchitecture(edges=simple_edges) - assert simple_architecture.top_level_nodes == {nodes[0]} + assert simple_architecture.top_level_nodes == {nodes['s1']} # Hierarchical architecture should have 1 top node hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) - assert hierarchical_architecture.top_level_nodes == {nodes[0]} + assert hierarchical_architecture.top_level_nodes == {nodes['s1']} # Centralised architecture should have 1 top node centralised_architecture = InformationArchitecture(edges=centralised_edges) - assert centralised_architecture.top_level_nodes == {nodes[0]} + assert centralised_architecture.top_level_nodes == {nodes['s1']} # Decentralised architecture should have 2 top nodes decentralised_architecture = InformationArchitecture(edges=decentralised_edges) - assert decentralised_architecture.top_level_nodes == {nodes[0], nodes[3]} + assert decentralised_architecture.top_level_nodes == {nodes['s1'], nodes['s4']} # Linear architecture should have 1 top node linear_architecture = InformationArchitecture(edges=linear_edges) - assert linear_architecture.top_level_nodes == {nodes[4]} + assert linear_architecture.top_level_nodes == {nodes['s5']} # Disconnected architecture should have 2 top nodes disconnected_architecture = InformationArchitecture(edges=disconnected_edges, force_connected=False) - assert disconnected_architecture.top_level_nodes == {nodes[0], nodes[2]} + assert disconnected_architecture.top_level_nodes == {nodes['s1'], nodes['s3']} # Circular architecture should have no top node circular_architecture = InformationArchitecture(edges=circular_edges) @@ -416,114 +332,115 @@ def test_top_level_nodes(fixtures): disconnected_loop_architecture = InformationArchitecture(edges=disconnected_loop_edges, force_connected=False) - assert disconnected_loop_architecture.top_level_nodes == {nodes[0]} + assert disconnected_loop_architecture.top_level_nodes == {nodes['s1']} -def test_number_of_leaves(fixtures): +def test_number_of_leaves(nodes, edge_lists): - hierarchical_edges = fixtures["hierarchical_edges"] - circular_edges = fixtures["circular_edges"] - nodes = fixtures["nodes"] + hierarchical_edges = edge_lists["hierarchical_edges"] + circular_edges = edge_lists["circular_edges"] hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) # Check number of leaves for top node and senders of top node - assert hierarchical_architecture.number_of_leaves(nodes[0]) == 3 - assert hierarchical_architecture.number_of_leaves(nodes[1]) == 2 - assert hierarchical_architecture.number_of_leaves(nodes[2]) == 1 + assert hierarchical_architecture.number_of_leaves(nodes['s1']) == 3 + assert hierarchical_architecture.number_of_leaves(nodes['s2']) == 2 + assert hierarchical_architecture.number_of_leaves(nodes['s3']) == 1 # Check number of leafs of a leaf node is 1 despite having no senders - assert hierarchical_architecture.number_of_leaves(nodes[6]) == 1 + assert hierarchical_architecture.number_of_leaves(nodes['s7']) == 1 circular_architecture = InformationArchitecture(edges=circular_edges) # Check any node in a circular architecture has no leaves - assert circular_architecture.number_of_leaves(nodes[0]) == 0 - assert circular_architecture.number_of_leaves(nodes[1]) == 0 - assert circular_architecture.number_of_leaves(nodes[2]) == 0 - assert circular_architecture.number_of_leaves(nodes[3]) == 0 - assert circular_architecture.number_of_leaves(nodes[4]) == 0 - - -def test_leaf_nodes(fixtures): - nodes = fixtures["nodes"] - simple_edges = fixtures["simple_edges"] - hierarchical_edges = fixtures["hierarchical_edges"] - centralised_edges = fixtures["centralised_edges"] - linear_edges = fixtures["linear_edges"] - decentralised_edges = fixtures["decentralised_edges"] - disconnected_edges = fixtures["disconnected_edges"] - circular_edges = fixtures["circular_edges"] + assert circular_architecture.number_of_leaves(nodes['s1']) == 0 + assert circular_architecture.number_of_leaves(nodes['s2']) == 0 + assert circular_architecture.number_of_leaves(nodes['s3']) == 0 + assert circular_architecture.number_of_leaves(nodes['s4']) == 0 + assert circular_architecture.number_of_leaves(nodes['s5']) == 0 + + +def test_leaf_nodes(nodes, edge_lists): + simple_edges = edge_lists["simple_edges"] + hierarchical_edges = edge_lists["hierarchical_edges"] + centralised_edges = edge_lists["centralised_edges"] + linear_edges = edge_lists["linear_edges"] + decentralised_edges = edge_lists["decentralised_edges"] + disconnected_edges = edge_lists["disconnected_edges"] + circular_edges = edge_lists["circular_edges"] # Simple architecture should have 2 leaf nodes simple_architecture = InformationArchitecture(edges=simple_edges) - assert simple_architecture.leaf_nodes == {nodes[1], nodes[2]} + assert simple_architecture.leaf_nodes == {nodes['s2'], nodes['s3']} # Hierarchical architecture should have 3 leaf nodes hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) - assert hierarchical_architecture.leaf_nodes == {nodes[3], nodes[4], nodes[6]} + assert hierarchical_architecture.leaf_nodes == {nodes['s4'], nodes['s5'], nodes['s7']} # Centralised architecture should have 2 leaf nodes centralised_architecture = InformationArchitecture(edges=centralised_edges) - assert centralised_architecture.leaf_nodes == {nodes[3], nodes[6]} + assert centralised_architecture.leaf_nodes == {nodes['s4'], nodes['s7']} # Decentralised architecture should have 2 leaf nodes decentralised_architecture = InformationArchitecture(edges=decentralised_edges) - assert decentralised_architecture.leaf_nodes == {nodes[2], nodes[1]} + assert decentralised_architecture.leaf_nodes == {nodes['s3'], nodes['s2']} # Linear architecture should have 1 leaf node linear_architecture = InformationArchitecture(edges=linear_edges) - assert linear_architecture.leaf_nodes == {nodes[0]} + assert linear_architecture.leaf_nodes == {nodes['s1']} # Disconnected architecture should have 2 leaf nodes disconnected_architecture = InformationArchitecture(edges=disconnected_edges, force_connected=False) - assert disconnected_architecture.leaf_nodes == {nodes[1], nodes[3]} + assert disconnected_architecture.leaf_nodes == {nodes['s2'], nodes['s4']} # Circular architecture should have no leaf nodes circular_architecture = InformationArchitecture(edges=circular_edges) assert circular_architecture.top_level_nodes == set() -def test_all_nodes(fixtures): - nodes = fixtures["nodes"] - simple_edges = fixtures["simple_edges"] - hierarchical_edges = fixtures["hierarchical_edges"] - centralised_edges = fixtures["centralised_edges"] - linear_edges = fixtures["linear_edges"] - decentralised_edges = fixtures["decentralised_edges"] - disconnected_edges = fixtures["disconnected_edges"] - circular_edges = fixtures["circular_edges"] +def test_all_nodes(nodes, edge_lists): + simple_edges = edge_lists["simple_edges"] + hierarchical_edges = edge_lists["hierarchical_edges"] + centralised_edges = edge_lists["centralised_edges"] + linear_edges = edge_lists["linear_edges"] + decentralised_edges = edge_lists["decentralised_edges"] + disconnected_edges = edge_lists["disconnected_edges"] + circular_edges = edge_lists["circular_edges"] # Simple architecture should have 3 nodes simple_architecture = InformationArchitecture(edges=simple_edges) - assert simple_architecture.all_nodes == {nodes[0], nodes[1], nodes[2]} + assert simple_architecture.all_nodes == {nodes['s1'], nodes['s2'], nodes['s3']} # Hierarchical architecture should have 7 nodes hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) - assert hierarchical_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], - nodes[4], nodes[5], nodes[6]} + assert hierarchical_architecture.all_nodes == {nodes['s1'], nodes['s2'], nodes['s3'], + nodes['s4'], nodes['s5'], nodes['s6'], + nodes['s7']} # Centralised architecture should have 7 nodes centralised_architecture = InformationArchitecture(edges=centralised_edges) - assert centralised_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], - nodes[4], nodes[5], nodes[6]} + assert centralised_architecture.all_nodes == {nodes['s1'], nodes['s2'], nodes['s3'], + nodes['s4'], nodes['s5'], nodes['s6'], + nodes['s7']} # Decentralised architecture should have 5 nodes decentralised_architecture = InformationArchitecture(edges=decentralised_edges) - assert decentralised_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], - nodes[4]} + assert decentralised_architecture.all_nodes == {nodes['s1'], nodes['s2'], nodes['s3'], + nodes['s4'], nodes['s5']} # Linear architecture should have 5 nodes linear_architecture = InformationArchitecture(edges=linear_edges) - assert linear_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], nodes[4]} + assert linear_architecture.all_nodes == {nodes['s1'], nodes['s2'], nodes['s3'], nodes['s4'], + nodes['s5']} # Disconnected architecture should have 4 nodes disconnected_architecture = InformationArchitecture(edges=disconnected_edges, force_connected=False) - assert disconnected_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3]} + assert disconnected_architecture.all_nodes == {nodes['s1'], nodes['s2'], nodes['s3'], + nodes['s4']} # Circular architecture should have 4 nodes circular_architecture = InformationArchitecture(edges=circular_edges) - assert circular_architecture.all_nodes == {nodes[0], nodes[1], nodes[2], nodes[3], nodes[4]} - + assert circular_architecture.all_nodes == {nodes['s1'], nodes['s2'], nodes['s3'], nodes['s4'], + nodes['s5']} From d375fc4ddc446314f6ab2c701c42306b4b80c8f7 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Thu, 24 Aug 2023 11:19:09 +0100 Subject: [PATCH 065/170] Allow monitoring of queue consumption for fuse node queues --- stonesoup/architecture/edge.py | 16 +++++++++++++--- stonesoup/architecture/node.py | 13 +++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index e63214856..d88ab13ab 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -19,12 +19,22 @@ class FusionQueue(Queue): Iterable, where it blocks attempting to yield items on the queue """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._to_consume = 0 + + def _put(self, *args, **kwargs): + super()._put(*args, **kwargs) + self._to_consume += 1 def __iter__(self): - return self + while True: + yield self.get() + self._to_consume -= 1 - def __next__(self): - return self.get() + @property + def to_consume(self): + return self._to_consume class DataPiece(Base): diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 0b9589d70..814575f7b 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -125,12 +125,13 @@ def fuse(self): try: data = self._track_queue.get(timeout=timeout) except Empty: - break - timeout = 0.1 - # track it - time, tracks = data - self.tracks.update(tracks) - updated_tracks |= tracks + if not self.fusion_queue.to_consume: + break + else: + timeout = 0.1 + time, tracks = data + self.tracks.update(tracks) + updated_tracks |= tracks if data is None or self.fusion_queue.unfinished_tasks: print(f"{self.label}: {self.fusion_queue.unfinished_tasks} still being processed") From 3559ab78c800a5a2739b2d08f255398231978666 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Thu, 24 Aug 2023 11:21:49 +0100 Subject: [PATCH 066/170] Add a WIP wrapper for switching between two updaters --- stonesoup/updater/wrapper.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 stonesoup/updater/wrapper.py diff --git a/stonesoup/updater/wrapper.py b/stonesoup/updater/wrapper.py new file mode 100644 index 000000000..b1678a797 --- /dev/null +++ b/stonesoup/updater/wrapper.py @@ -0,0 +1,23 @@ +from . import Updater +from ..base import Property +from ..types.detection import GaussianDetection + + +class DetectionAndTrackSwitchingUpdater(Updater): + + detection_updater: Updater = Property() + track_updater: Updater = Property() + + def predict_measurement(self, state_prediction, measurement_model=None, **kwargs): + if measurement_model.ndim == state_prediction.ndim: + return self.track_updater.predict_measurement( + state_prediction, measurement_model, **kwargs) + else: + return self.detection_updater.predict_measurement( + state_prediction, measurement_model, **kwargs) + + def update(self, hypothesis, **kwargs): + if isinstance(hypothesis.measurement, GaussianDetection): + return self.track_updater.update(hypothesis, **kwargs) + else: + return self.detection_updater.update(hypothesis, **kwargs) \ No newline at end of file From fea112b3c183bba5c21e3bbcf1a12f079a2889bd Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 24 Aug 2023 12:00:34 +0100 Subject: [PATCH 067/170] Add docstrings to architecture.__init__.py --- stonesoup/architecture/__init__.py | 45 ++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 649455dde..399d4b717 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -1,6 +1,6 @@ from abc import abstractmethod from ..base import Base, Property -from .node import Node, SensorNode, RepeaterNode, FusionNode +from .node import Node, SensorNode, RepeaterNode, FusionNode, SensorFusionNode from .edge import Edges, DataPiece from ..types.groundtruth import GroundTruthPath from ..types.detection import TrueDetection, Clutter @@ -91,6 +91,11 @@ def senders(self, node: Node): @property def shortest_path_dict(self): + """ + Returns a dictionary where dict[key1][key2] gives the distance of the shortest path + from node1 to node2 if key1=node1 and key2=node2. If no path exists from node1 to node2, + a KeyError is raised. + """ path = nx.all_pairs_shortest_path_length(self.di_graph) dpath = {x[0]: x[1] for x in path} return dpath @@ -119,6 +124,10 @@ def top_level_nodes(self): return top_nodes def number_of_leaves(self, node: Node): + """ + Returns the number of leaf nodes which are connected to the node given as a parameter by a + path from the leaf node to the parameter node. + """ node_leaves = set() non_leaves = 0 @@ -137,6 +146,10 @@ def number_of_leaves(self, node: Node): @property def leaf_nodes(self): + """ + Returns all the nodes in the :class:`Architecture` which have no sender nodes. i.e. all + nodes that do not receive any data from other nodes. + """ leaf_nodes = set() for node in self.all_nodes: if len(self.senders(node)) == 0: @@ -150,10 +163,16 @@ def propagate(self, time_increment: float): @property def all_nodes(self): + """ + Returns a set of all Nodes in the :class:`Architecture`. + """ return set(self.di_graph.nodes) @property def sensor_nodes(self): + """ + Returns a set of all SensorNodes in the :class:`Architecture`. + """ sensor_nodes = set() for node in self.all_nodes: if isinstance(node, SensorNode): @@ -162,12 +181,26 @@ def sensor_nodes(self): @property def fusion_nodes(self): + """ + Returns a set of all FusionNodes in the :class:`Architecture`. + """ fusion = set() for node in self.all_nodes: if isinstance(node, FusionNode): fusion.add(node) return fusion + @property + def sensor_fusion_nodes(self): + """ + Returns a set of all SensorFusionNodes in the :class:`Architecture`. + """ + sensorfusion = set() + for node in self.all_nodes: + if isinstance(node, SensorFusionNode): + sensorfusion.add(node) + return sensorfusion + def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, bgcolour="lightgray", node_style="filled", save_plot=True, plot_style=None): """Creates a pdf plot of the directed graph and displays it @@ -293,7 +326,9 @@ def density(self): @property def is_hierarchical(self): - """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`""" + """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`. Uses + the following logic: An architecture is hierarchical if and only if there exists only + one node with 0 recipients, all other nodes have exactly 1 recipient.""" if not len(self.top_level_nodes) == 1: return False for node in self.all_nodes: @@ -303,6 +338,12 @@ def is_hierarchical(self): @property def is_centralised(self): + """ + Returns 'True' if the :class:`Architecture` is hierarchical, otherwise 'False'. + Uses the following logic: An architecture is centralised if and only if there exists only + one node with 0 recipients, and there exists a path to this node from every other node in + the architecture. + """ top_nodes = self.top_level_nodes if len(top_nodes) != 1: return False From b47a61ecff7e0e9fa9d791b69fd767b2911c65ad Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Thu, 24 Aug 2023 13:15:09 +0100 Subject: [PATCH 068/170] test_edge mostly finished --- stonesoup/architecture/edge.py | 44 +++++++++++----- stonesoup/architecture/tests/conftest.py | 6 ++- stonesoup/architecture/tests/test_edge.py | 63 +++++++++++++++++++++-- 3 files changed, 94 insertions(+), 19 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index d88ab13ab..50f0d372d 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -1,5 +1,5 @@ from ..base import Base, Property -from ..types.time import TimeRange +from ..types.time import TimeRange, CompoundTimeRange from ..types.track import Track from ..types.detection import Detection from ..types.hypothesis import Hypothesis @@ -67,9 +67,11 @@ class Edge(Base): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + if not isinstance(self.edge_latency, float): + raise TypeError(f"edge_latency should be a float, not a {type(self.edge_latency)}") self.messages_held = {"pending": {}, # For pending, messages indexed by time sent. "received": {}} # For received, by time received - self.time_ranges_failed = [] # List of time ranges during which this edge was failed + self.time_range_failed = CompoundTimeRange() # Times during which this edge was failed def send_message(self, data_piece, time_pertaining, time_sent): if not isinstance(data_piece, DataPiece): @@ -102,10 +104,11 @@ def update_messages(self, current_time): if len(self.messages_held['pending'][time]) == 0: del self.messages_held['pending'][time] - def failed(self, current_time, duration): - """Keeps track of when this edge was failed using the time_ranges_failed property. """ - end_time = current_time + timedelta(duration) - self.time_ranges_failed.append(TimeRange(current_time, end_time)) + def failed(self, current_time, delta): + """"Keeps track of when this edge was failed using the time_ranges_failed property. + Delta should be a timedelta instance""" + end_time = current_time + delta + self.time_range_failed.add(TimeRange(current_time, end_time)) @property def sender(self): @@ -131,11 +134,24 @@ def unsent_data(self): unsent.append((data_piece, time_pertaining)) return unsent + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + return all(getattr(self, name) == getattr(other, name) for name in type(self).properties) + + def __hash__(self): + return hash(tuple(getattr(self, name) for name in type(self).properties)) + class Edges(Base, Collection): """Container class for Edge""" edges: List[Edge] = Property(doc="List of Edge objects", default=None) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.edges is None: + self.edges = [] + def __iter__(self): return self.edges.__iter__() @@ -159,10 +175,9 @@ def get(self, node_pair): @property def edge_list(self): """Returns a list of tuples in the form (sender, recipient)""" - edge_list = [] - for edge in self.edges: - edge_list.append(edge.nodes) - return edge_list + if not self.edges: + return [] + return [edge.nodes for edge in self.edges] def __len__(self): return len(self.edges) @@ -197,17 +212,18 @@ def recipient_node(self): @property def arrival_time(self): - # TODO: incorporate failed time ranges here. Not essential for a first PR. Could do with merging of PR #664 + # TODO: incorporate failed time ranges here. + # Not essential for a first PR. Could do with merging of PR #664 return self.time_sent + timedelta(seconds=self.edge.ovr_latency) def update(self, current_time): progress = (current_time - self.time_sent).total_seconds() + if progress < 0: + raise ValueError("Current time cannot be before the Message was sent") if progress < self.edge.sender.latency: self.status = "sending" - elif progress < self.edge.sender.latency + self.edge.edge_latency: - self.status = "transferring" elif progress < self.edge.ovr_latency: - self.status = "receiving" + self.status = "transferring" else: self.status = "received" diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 3b4565fab..82f994602 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -12,8 +12,10 @@ @pytest.fixture def edges(): - edge_a = Edge((Node(label="edge_a sender"), Node(label="edge_a_recipient"))) - return {'a': edge_a} + edge_a = Edge((Node(label="edge_a sender"), Node(label="edge_a recipient"))) + edge_b = Edge((Node(label="edge_b sender"), Node(label="edge_b recipient"))) + edge_c = Edge((Node(label="edge_c sender"), Node(label="edge_c recipient"))) + return {'a': edge_a, 'b': edge_b, 'c': edge_c} @pytest.fixture diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index e6beb2cd0..ba8d50864 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -2,6 +2,9 @@ from ..edge import Edges, Edge, DataPiece, Message from ...types.track import Track +from ...types.time import CompoundTimeRange, TimeRange + +from datetime import timedelta def test_data_piece(nodes, times): @@ -16,7 +19,9 @@ def test_data_piece(nodes, times): def test_edge_init(nodes, times, data_pieces): with pytest.raises(TypeError): - edge_fail = Edge() + Edge() + with pytest.raises(TypeError): + Edge(nodes['a'], nodes['b']) # e.g. forgetting to put nodes inside a tuple edge = Edge((nodes['a'], nodes['b'])) assert edge.edge_latency == 0.0 assert edge.sender == nodes['a'] @@ -56,5 +61,57 @@ def test_send_update_message(edges, times, data_pieces): assert message in edge.messages_held['received'][times['a']] -def test_failed(): - assert True \ No newline at end of file +def test_failed(edges, times): + edge = edges['a'] + assert edge.time_range_failed == CompoundTimeRange() + edge.failed(times['a'], timedelta(seconds=5)) + new_time_range = TimeRange(times['a'], times['a'] + timedelta(seconds=5)) + assert edge.time_range_failed == CompoundTimeRange([new_time_range]) + + +def test_edges(edges, nodes): + edges_list = Edges([edges['a'], edges['b']]) + assert edges_list.edges == [edges['a'], edges['b']] + edges_list.add(edges['c']) + assert edges['c'] in edges_list + edges_list.add(Edge((nodes['a'], nodes['b']))) + assert len(edges_list) == 4 + assert (nodes['a'], nodes['b']) in edges_list.edge_list + assert (nodes['a'], nodes['b']) in edges_list.edge_list + + empty_edges = Edges() + assert len(empty_edges) == 0 + assert empty_edges.edge_list == [] + assert empty_edges.edges == [] + assert [edge for edge in empty_edges] == [] + empty_edges.add(Edge((nodes['a'], nodes['b']))) + assert [edge for edge in empty_edges] == [Edge((nodes['a'], nodes['b']))] + + +def test_message(edges, data_pieces, times): + with pytest.raises(TypeError): + Message() + edge = edges['a'] + message = Message(edge=edge, time_pertaining=times['a'], time_sent=times['b'], + data_piece=data_pieces['a']) + assert message.sender_node == edge.sender + assert message.recipient_node == edge.recipient + edge.edge_latency = 5.0 + edge.sender.latency = 1.0 + assert message.arrival_time == times['b'] + timedelta(seconds=6.0) + assert message.status == 'sending' + with pytest.raises(ValueError): + message.update(times['a']) + + message.update(times['b']) + assert message.status == 'sending' + + message.update(times['b'] + timedelta(seconds=3)) + assert message.status == 'transferring' + + message.update(times['b'] + timedelta(seconds=8)) + assert message.status == 'received' + + +def test_fusion_queue(): + assert True From 822baf9dbf5d74620bb27b173576c14b4e167aa5 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Thu, 24 Aug 2023 14:13:32 +0100 Subject: [PATCH 069/170] Make interface for FusionQueue clearer --- stonesoup/architecture/edge.py | 13 ++++++++++--- stonesoup/architecture/node.py | 6 +++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 50f0d372d..b82a4d1f8 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -22,19 +22,26 @@ class FusionQueue(Queue): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._to_consume = 0 + self._consuming = False def _put(self, *args, **kwargs): super()._put(*args, **kwargs) self._to_consume += 1 def __iter__(self): + if self._consuming: + raise RuntimeError("Queue can only be iterated over once.") + self._consuming = True while True: - yield self.get() + yield super().get() self._to_consume -= 1 @property - def to_consume(self): - return self._to_consume + def waiting_for_data(self): + return self._consuming and self._to_consume + + def get(self, *args, **kwargs): + raise NotImplementedError("Getting items from queue must use iteration") class DataPiece(Base): diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 814575f7b..b9bccce01 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -1,7 +1,7 @@ import copy import threading from datetime import datetime -from queue import Empty +from queue import Queue, Empty from typing import Tuple from ..base import Property, Base @@ -101,7 +101,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.tracks = set() # Set of tracks this Node has recorded - self._track_queue = FusionQueue() + self._track_queue = Queue() self._tracking_thread = threading.Thread( target=self._track_thread, args=(self.tracker, self.fusion_queue, self._track_queue), @@ -125,7 +125,7 @@ def fuse(self): try: data = self._track_queue.get(timeout=timeout) except Empty: - if not self.fusion_queue.to_consume: + if not self.fusion_queue.waiting_for_data: break else: timeout = 0.1 From c22a10faf319ee172dd3bd8093a0230140efff5c Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Thu, 24 Aug 2023 14:23:05 +0100 Subject: [PATCH 070/170] Allow architecture latency to be any type of number Not just float --- stonesoup/architecture/edge.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index b82a4d1f8..282819631 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -1,3 +1,9 @@ +from collections.abc import Collection +from typing import Union, Tuple, List, TYPE_CHECKING +from numbers import Number +from datetime import datetime, timedelta +from queue import Queue + from ..base import Base, Property from ..types.time import TimeRange, CompoundTimeRange from ..types.track import Track @@ -5,11 +11,6 @@ from ..types.hypothesis import Hypothesis from .functions import _dict_set -from collections.abc import Collection -from typing import Union, Tuple, List, TYPE_CHECKING -from datetime import datetime, timedelta -from queue import Queue - if TYPE_CHECKING: from .node import Node @@ -74,7 +75,7 @@ class Edge(Base): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not isinstance(self.edge_latency, float): + if not isinstance(self.edge_latency, Number): raise TypeError(f"edge_latency should be a float, not a {type(self.edge_latency)}") self.messages_held = {"pending": {}, # For pending, messages indexed by time sent. "received": {}} # For received, by time received From 42a50e44bf10881b89585a2311ad32d63faf1ad5 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Thu, 24 Aug 2023 14:51:34 +0100 Subject: [PATCH 071/170] Fix logic with FusionNode in architectures --- stonesoup/architecture/node.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index b9bccce01..ff72231f7 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -107,16 +107,13 @@ def __init__(self, *args, **kwargs): args=(self.tracker, self.fusion_queue, self._track_queue), daemon=True) - def update(self, *args, **kwargs): - added = super().update(*args, **kwargs) + def fuse(self): if not self._tracking_thread.is_alive(): try: self._tracking_thread.start() except RuntimeError: pass # Previously started - return added - def fuse(self): data = None added = False timeout = self.latency or 0.1 @@ -125,7 +122,7 @@ def fuse(self): try: data = self._track_queue.get(timeout=timeout) except Empty: - if not self.fusion_queue.waiting_for_data: + if self.fusion_queue.waiting_for_data: break else: timeout = 0.1 From 46dc20ebe4c089f916fef62c773871434149a73b Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Thu, 24 Aug 2023 15:29:43 +0100 Subject: [PATCH 072/170] finished test_edge in architectures --- stonesoup/architecture/edge.py | 2 +- stonesoup/architecture/tests/test_edge.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 282819631..64d2b81fa 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -39,7 +39,7 @@ def __iter__(self): @property def waiting_for_data(self): - return self._consuming and self._to_consume + return self._consuming and not self._to_consume def get(self, *args, **kwargs): raise NotImplementedError("Getting items from queue must use iteration") diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index ba8d50864..8b9bf8be3 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -1,6 +1,6 @@ import pytest -from ..edge import Edges, Edge, DataPiece, Message +from ..edge import Edges, Edge, DataPiece, Message, FusionQueue from ...types.track import Track from ...types.time import CompoundTimeRange, TimeRange @@ -114,4 +114,15 @@ def test_message(edges, data_pieces, times): def test_fusion_queue(): - assert True + q = FusionQueue() + iter_q = iter(q) + assert q._to_consume == 0 + q.put("item") + q.put("another item") + assert q._to_consume == 2 + a = next(iter_q) + assert a == "item" + assert q.to_consume == 2 + b = next(iter_q) + assert b == "another item" + assert q.to_consume == 1 \ No newline at end of file From 6e07082ece0c89fcf987573ecc57da9974dddbf8 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 24 Aug 2023 16:03:11 +0100 Subject: [PATCH 073/170] Added fixtures for fusion network to conftest.py and further tests to test_architecture.py --- stonesoup/architecture/tests/conftest.py | 244 ++++++++++++++++-- .../architecture/tests/test_architecture.py | 73 ++++++ 2 files changed, 299 insertions(+), 18 deletions(-) diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 82f994602..abe63f98a 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -1,13 +1,40 @@ import pytest -from datetime import datetime + import numpy as np +import random +from ordered_set import OrderedSet +from datetime import datetime, timedelta +import copy from ..edge import Edge, DataPiece, Edges from ..node import Node, RepeaterNode, SensorNode, FusionNode, SensorFusionNode from ...types.track import Track from ...sensor.categorical import HMMSensor from ...models.measurement.categorical import MarkovianMeasurementModel +from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, ConstantVelocity +from stonesoup.types.groundtruth import GroundTruthPath, GroundTruthState +from ordered_set import OrderedSet +from stonesoup.types.state import StateVector +from stonesoup.sensor.radar.radar import RadarRotatingBearingRange +from stonesoup.types.angle import Angle +from stonesoup.architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ + CombinedArchitecture +from stonesoup.architecture.node import FusionNode, RepeaterNode, SensorNode, SensorFusionNode +from stonesoup.architecture.edge import Edge, Edges +from stonesoup.predictor.kalman import KalmanPredictor +from stonesoup.updater.kalman import ExtendedKalmanUpdater +from stonesoup.hypothesiser.distance import DistanceHypothesiser +from stonesoup.measures import Mahalanobis +from stonesoup.dataassociator.neighbour import GNNWith2DAssignment +from stonesoup.deleter.error import CovarianceBasedDeleter +from stonesoup.types.state import GaussianState +from stonesoup.initiator.simple import MultiMeasurementInitiator +from stonesoup.tracker.simple import MultiTargetTracker +from stonesoup.architecture.edge import FusionQueue +from stonesoup.updater.wrapper import DetectionAndTrackSwitchingUpdater +from stonesoup.updater.chernoff import ChernoffUpdater +from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder @pytest.fixture @@ -49,7 +76,195 @@ def nodes(): @pytest.fixture -def edge_lists(nodes): +def data_pieces(times, nodes): + data_piece_a = DataPiece(node=nodes['a'], originator=nodes['a'], + data=Track([]), time_arrived=times['a']) + data_piece_b = DataPiece(node=nodes['a'], originator=nodes['b'], + data=Track([]), time_arrived=times['b']) + return {'a': data_piece_a, 'b': data_piece_b} + + +@pytest.fixture +def times(): + time_a = datetime.strptime("23/08/2023 13:36:00", "%d/%m/%Y %H:%M:%S") + time_b = datetime.strptime("23/08/2023 13:37:00", "%d/%m/%Y %H:%M:%S") + start_time = datetime.now().replace(microsecond=0) + return {'a': time_a, 'b': time_b, 'start': start_time} + + +@pytest.fixture +def transition_model(): + transition_model = CombinedLinearGaussianTransitionModel([ConstantVelocity(0.005), + ConstantVelocity(0.005)]) + return transition_model + + +@pytest.fixture +def ground_truths(transition_model, times): + start_time = times["start"] + yps = range(0, 100, 10) # y value for prior state + truths = OrderedSet() + ntruths = 3 # number of ground truths in simulation + time_max = 60 # timestamps the simulation is observed over + timesteps = [start_time + timedelta(seconds=k) for k in range(time_max)] + + xdirection = 1 + ydirection = 1 + + # Generate ground truths + for j in range(0, ntruths): + truth = GroundTruthPath([GroundTruthState([0, xdirection, yps[j], ydirection], + timestamp=timesteps[0])], id=f"id{j}") + + for k in range(1, time_max): + truth.append( + GroundTruthState(transition_model.function(truth[k - 1], noise=True, + time_interval=timedelta(seconds=1)), + timestamp=timesteps[k])) + truths.add(truth) + + xdirection *= -1 + if j % 2 == 0: + ydirection *= -1 + + return truths + + +@pytest.fixture +def radar_sensors(times): + start_time = times["start"] + total_no_sensors = 5 + sensor_set = OrderedSet() + for n in range(0, total_no_sensors): + sensor = RadarRotatingBearingRange( + position_mapping=(0, 2), + noise_covar=np.array([[np.radians(0.5) ** 2, 0], + [0, 1 ** 2]]), + ndim_state=4, + position=np.array([[10], [n * 20 - 40]]), + rpm=60, + fov_angle=np.radians(360), + dwell_centre=StateVector([0.0]), + max_range=np.inf, + resolutions={'dwell_centre': Angle(np.radians(30))} + ) + sensor_set.add(sensor) + for sensor in sensor_set: + sensor.timestamp = start_time + + return sensor_set + + +@pytest.fixture +def predictor(): + predictor = KalmanPredictor(transition_model) + return predictor + + +@pytest.fixture +def updater(transition_model): + updater = ExtendedKalmanUpdater(measurement_model=None) + return updater + + +@pytest.fixture +def hypothesiser(predictor, updater): + hypothesiser = DistanceHypothesiser(predictor, updater, measure=Mahalanobis(), + missed_distance=5) + return hypothesiser + + +@pytest.fixture +def data_associator(hypothesiser): + data_associator = GNNWith2DAssignment(hypothesiser) + return data_associator + + +@pytest.fixture +def deleter(hypothesiser): + deleter = CovarianceBasedDeleter(covar_trace_thresh=7) + return deleter + + +@pytest.fixture +def initiator(): + initiator = MultiMeasurementInitiator( + prior_state=GaussianState([[0], [0], [0], [0]], np.diag([0, 1, 0, 1])), + measurement_model=None, + deleter=deleter, + data_associator=data_associator, + updater=updater, + min_points=2, + ) + return initiator + + +@pytest.fixture +def tracker(): + tracker = MultiTargetTracker(initiator, deleter, None, data_associator, updater) + return tracker + + +@pytest.fixture +def track_updater(): + track_updater = ChernoffUpdater(None) + return track_updater + + +@pytest.fixture +def detection_updater(): + detection_updater = ExtendedKalmanUpdater(None) + return detection_updater + + +@pytest.fixture +def detection_track_updater(): + detection_track_updater = DetectionAndTrackSwitchingUpdater(None, detection_updater, + track_updater) + return detection_track_updater + + +@pytest.fixture +def fusion_queue(): + fq = FusionQueue() + return fq + + +@pytest.fixture +def track_tracker(): + track_tracker = MultiTargetTracker( + initiator, deleter, Tracks2GaussianDetectionFeeder(fq), data_associator, + detection_track_updater) + return track_tracker + + +@pytest.fixture +def radar_nodes(radar_sensors, fusion_queue): + sensor_set = radar_sensors + node_A = SensorNode(sensor=sensor_set[0]) + node_B = SensorNode(sensor=sensor_set[2]) + + node_C_tracker = copy.deepcopy(tracker) + node_C_tracker.detector = FusionQueue() + node_C = FusionNode(tracker=node_C_tracker, fusion_queue=node_C_tracker.detector, latency=0) + + node_D = SensorNode(sensor=sensor_set[1]) + node_E = SensorNode(sensor=sensor_set[3]) + + node_F_tracker = copy.deepcopy(tracker) + node_F_tracker.detector = FusionQueue() + node_F = FusionNode(tracker=node_F_tracker, fusion_queue=node_F_tracker.detector, latency=0) + + node_H = SensorNode(sensor=sensor_set[4]) + + node_G = FusionNode(tracker=track_tracker, fusion_queue=fusion_queue, latency=0) + + return {'a': node_A, 'b': node_B, 'c': node_C, 'd': node_D, 'e': node_E, 'f': node_F, + 'g': node_G, 'h': node_H} + + +@pytest.fixture +def edge_lists(nodes, radar_nodes): hierarchical_edges = Edges([Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s3'], nodes['s1'])), Edge((nodes['s4'], nodes['s2'])), Edge((nodes['s5'], nodes['s2'])), Edge((nodes['s6'], nodes['s3'])), Edge((nodes['s7'], nodes['s6']))]) @@ -87,25 +302,18 @@ def edge_lists(nodes): [Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s4'], nodes['s3'])), Edge((nodes['s3'], nodes['s4']))]) + radar_edges = Edges([Edge((radar_nodes['a'], radar_nodes['c'])), + Edge((radar_nodes['b'], radar_nodes['c'])), + Edge((radar_nodes['d'], radar_nodes['f'])), + Edge((radar_nodes['e'], radar_nodes['f'])), + Edge((radar_nodes['c'], radar_nodes['g']), edge_latency=0), + Edge((radar_nodes['f'], radar_nodes['g']), edge_latency=0), + Edge((radar_nodes['h'], radar_nodes['g']))]) + return {"hierarchical_edges": hierarchical_edges, "centralised_edges": centralised_edges, "simple_edges": simple_edges, "linear_edges": linear_edges, "decentralised_edges": decentralised_edges, "disconnected_edges": disconnected_edges, "k4_edges": k4_edges, "circular_edges": circular_edges, - "disconnected_loop_edges": disconnected_loop_edges} - - - -@pytest.fixture -def data_pieces(times, nodes): - data_piece_a = DataPiece(node=nodes['a'], originator=nodes['a'], - data=Track([]), time_arrived=times['a']) - data_piece_b = DataPiece(node=nodes['a'], originator=nodes['b'], - data=Track([]), time_arrived=times['b']) - return {'a': data_piece_a, 'b': data_piece_b} + "disconnected_loop_edges": disconnected_loop_edges, "radar_edges": radar_edges} -@pytest.fixture -def times(): - time_a = datetime.strptime("23/08/2023 13:36:00", "%d/%m/%Y %H:%M:%S") - time_b = datetime.strptime("23/08/2023 13:37:00", "%d/%m/%Y %H:%M:%S") - return {'a': time_a, 'b': time_b} diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 38a7093bc..e5cde2d13 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -444,3 +444,76 @@ def test_all_nodes(nodes, edge_lists): circular_architecture = InformationArchitecture(edges=circular_edges) assert circular_architecture.all_nodes == {nodes['s1'], nodes['s2'], nodes['s3'], nodes['s4'], nodes['s5']} + + +def test_sensor_nodes(edge_lists, ground_truths, radar_nodes): + radar_edges = edge_lists["radar_edges"] + hierarchical_edges = edge_lists["hierarchical_edges"] + + network = InformationArchitecture(edges=radar_edges) + + assert network.sensor_nodes == {radar_nodes['a'], radar_nodes['b'], radar_nodes['d'], + radar_nodes['e'], radar_nodes['h']} + + h_arch = InformationArchitecture(edges=hierarchical_edges) + + assert h_arch.sensor_nodes == h_arch.all_nodes + assert len(h_arch.sensor_nodes) == 7 + + +def test_fusion_nodes(edge_lists, ground_truths, radar_nodes): + radar_edges = edge_lists["radar_edges"] + hierarchical_edges = edge_lists["hierarchical_edges"] + + network = InformationArchitecture(edges=radar_edges) + + assert network.fusion_nodes == {radar_nodes['c'], radar_nodes['f'], radar_nodes['g']} + + h_arch = InformationArchitecture(edges=hierarchical_edges) + + assert h_arch.fusion_nodes == set() + + +def test_information_arch_measure(edge_lists, ground_truths, times): + edges = edge_lists["radar_edges"] + start_time = times['start'] + + network = InformationArchitecture(edges=edges) + all_detections = network.measure(ground_truths=ground_truths, current_time=start_time) + + sensornodes = network.sensor_nodes + + # Check all_detections is a dictionary + assert type(all_detections) == dict + + # Check that number all_detections contains data for all sensor nodes + assert all_detections.keys() == network.sensor_nodes + + # Check that correct number of detections recorded for each sensor node is equal to the number + # of targets + for sensornode in sensornodes: + assert(len(all_detections[sensornode])) == 3 + + # Reset and repeat first 2 assertions with noise=False + edges = edge_lists["radar_edges"] + start_time = times['start'] + network = InformationArchitecture(edges=edges) + all_detections = network.measure(ground_truths=ground_truths, current_time=start_time) + + assert type(all_detections) == dict + assert all_detections.keys() == network.sensor_nodes + for sensornode in sensornodes: + assert(len(all_detections[sensornode])) == 3 + + # Reset and repeat first 2 assertions with no ground truth paths + edges = edge_lists["radar_edges"] + start_time = times['start'] + network = InformationArchitecture(edges=edges) + all_detections = network.measure(ground_truths=[], current_time=start_time) + + assert type(all_detections) == dict + assert all_detections.keys() == network.sensor_nodes + + # There should exist a key for each sensor node containing an empty list + for sensornode in sensornodes: + assert(len(all_detections[sensornode])) == 0 From f9d05da15dbec391651c3a5dd413dab0af1eaa5a Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Thu, 24 Aug 2023 16:58:07 +0100 Subject: [PATCH 074/170] finished test_edge in architectures --- stonesoup/architecture/tests/test_edge.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 8b9bf8be3..79ee005c8 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -117,12 +117,18 @@ def test_fusion_queue(): q = FusionQueue() iter_q = iter(q) assert q._to_consume == 0 + assert not q.waiting_for_data + assert not q._consuming q.put("item") q.put("another item") + + with pytest.raises(NotImplementedError): + q.get("anything") + assert q._to_consume == 2 a = next(iter_q) assert a == "item" - assert q.to_consume == 2 + assert q._to_consume == 2 b = next(iter_q) assert b == "another item" - assert q.to_consume == 1 \ No newline at end of file + assert q._to_consume == 1 From 0204992540b5914beb05acb570dacf632367df7a Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Thu, 24 Aug 2023 17:07:37 +0100 Subject: [PATCH 075/170] Remove timeout constraint on FuseNode in architectures --- stonesoup/architecture/node.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index ff72231f7..6d890083e 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -111,28 +111,23 @@ def fuse(self): if not self._tracking_thread.is_alive(): try: self._tracking_thread.start() - except RuntimeError: - pass # Previously started + except RuntimeError: # Previously started + raise RuntimeError(f"Tracking thread in {self.label!r} unexpectedly ended") - data = None added = False - timeout = self.latency or 0.1 updated_tracks = set() while True: + waiting_for_data = self.fusion_queue.waiting_for_data try: - data = self._track_queue.get(timeout=timeout) + data = self._track_queue.get(timeout=1e-6) except Empty: - if self.fusion_queue.waiting_for_data: + if not self._tracking_thread.is_alive() or waiting_for_data: break else: - timeout = 0.1 time, tracks = data self.tracks.update(tracks) updated_tracks |= tracks - if data is None or self.fusion_queue.unfinished_tasks: - print(f"{self.label}: {self.fusion_queue.unfinished_tasks} still being processed") - for track in updated_tracks: data_piece = DataPiece(self, self, copy.copy(track), track.timestamp, True) added, self.data_held['fused'] = _dict_set( From daf46c9f5bf058e5b66884ef580831d4f7f61256 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 24 Aug 2023 17:58:55 +0100 Subject: [PATCH 076/170] Tests for architecture.__init__.py except for .propagate() --- stonesoup/architecture/__init__.py | 11 --- stonesoup/architecture/tests/conftest.py | 4 +- .../architecture/tests/test_architecture.py | 96 ++++++++++++++++--- stonesoup/types/state.py | 2 +- 4 files changed, 88 insertions(+), 25 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 399d4b717..59ed37cc2 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -190,17 +190,6 @@ def fusion_nodes(self): fusion.add(node) return fusion - @property - def sensor_fusion_nodes(self): - """ - Returns a set of all SensorFusionNodes in the :class:`Architecture`. - """ - sensorfusion = set() - for node in self.all_nodes: - if isinstance(node, SensorFusionNode): - sensorfusion.add(node) - return sensorfusion - def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, bgcolour="lightgray", node_style="filled", save_plot=True, plot_style=None): """Creates a pdf plot of the directed graph and displays it diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index abe63f98a..dd9ee7c31 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -88,7 +88,7 @@ def data_pieces(times, nodes): def times(): time_a = datetime.strptime("23/08/2023 13:36:00", "%d/%m/%Y %H:%M:%S") time_b = datetime.strptime("23/08/2023 13:37:00", "%d/%m/%Y %H:%M:%S") - start_time = datetime.now().replace(microsecond=0) + start_time = datetime.strptime("25/12/1306 23:47:00", "%d/%m/%Y %H:%M:%S") return {'a': time_a, 'b': time_b, 'start': start_time} @@ -233,7 +233,7 @@ def fusion_queue(): @pytest.fixture def track_tracker(): track_tracker = MultiTargetTracker( - initiator, deleter, Tracks2GaussianDetectionFeeder(fq), data_associator, + initiator, deleter, Tracks2GaussianDetectionFeeder(fusion_queue), data_associator, detection_track_updater) return track_tracker diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index e5cde2d13..4433c87b7 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -1,8 +1,9 @@ import pytest from stonesoup.architecture import InformationArchitecture -from ..edge import Edge, Edges -from ..node import RepeaterNode +from ..edge import Edge, Edges, DataPiece +from ..node import RepeaterNode# +from stonesoup.types.detection import TrueDetection def test_hierarchical_plot(tmpdir, nodes, edge_lists): @@ -205,6 +206,10 @@ def test_is_connected(edge_lists): force_connected=False) assert disconnected_architecture.is_connected is False + # Raise error with force_connected=True on a disconnected graph + with pytest.raises(ValueError): + _ = InformationArchitecture(edges=disconnected_edges) + def test_recipients(nodes, edge_lists): centralised_edges = edge_lists["centralised_edges"] @@ -474,6 +479,40 @@ def test_fusion_nodes(edge_lists, ground_truths, radar_nodes): assert h_arch.fusion_nodes == set() +def test_len(edge_lists): + simple_edges = edge_lists["simple_edges"] + hierarchical_edges = edge_lists["hierarchical_edges"] + centralised_edges = edge_lists["centralised_edges"] + linear_edges = edge_lists["linear_edges"] + decentralised_edges = edge_lists["decentralised_edges"] + disconnected_edges = edge_lists["disconnected_edges"] + + # Simple architecture should be connected + simple_architecture = InformationArchitecture(edges=simple_edges) + assert len(simple_architecture) == len(simple_architecture.all_nodes) + + # Hierarchical architecture should be connected + hierarchical_architecture = InformationArchitecture(edges=hierarchical_edges) + assert len(hierarchical_architecture) == len(hierarchical_architecture.all_nodes) + + # Centralised architecture should be connected + centralised_architecture = InformationArchitecture(edges=centralised_edges) + assert len(centralised_architecture) == len(centralised_architecture.all_nodes) + + # Decentralised architecture should be connected + decentralised_architecture = InformationArchitecture(edges=decentralised_edges) + assert len(decentralised_architecture) == len(decentralised_architecture.all_nodes) + + # Linear architecture should be connected + linear_architecture = InformationArchitecture(edges=linear_edges) + assert len(linear_architecture) == len(linear_architecture.all_nodes) + + # Disconnected architecture should not be connected + disconnected_architecture = InformationArchitecture(edges=disconnected_edges, + force_connected=False) + assert len(disconnected_architecture) == len(disconnected_architecture.all_nodes) + + def test_information_arch_measure(edge_lists, ground_truths, times): edges = edge_lists["radar_edges"] start_time = times['start'] @@ -481,8 +520,6 @@ def test_information_arch_measure(edge_lists, ground_truths, times): network = InformationArchitecture(edges=edges) all_detections = network.measure(ground_truths=ground_truths, current_time=start_time) - sensornodes = network.sensor_nodes - # Check all_detections is a dictionary assert type(all_detections) == dict @@ -491,29 +528,66 @@ def test_information_arch_measure(edge_lists, ground_truths, times): # Check that correct number of detections recorded for each sensor node is equal to the number # of targets - for sensornode in sensornodes: + for sensornode in network.sensor_nodes: assert(len(all_detections[sensornode])) == 3 + assert type(all_detections[sensornode]) == set + for detection in all_detections[sensornode]: + assert type(detection) == TrueDetection + - # Reset and repeat first 2 assertions with noise=False +def test_information_arch_measure_no_noise(edge_lists, ground_truths, times): edges = edge_lists["radar_edges"] start_time = times['start'] network = InformationArchitecture(edges=edges) - all_detections = network.measure(ground_truths=ground_truths, current_time=start_time) + all_detections = network.measure(ground_truths=ground_truths, current_time=start_time, + noise=False) assert type(all_detections) == dict assert all_detections.keys() == network.sensor_nodes - for sensornode in sensornodes: + for sensornode in network.sensor_nodes: assert(len(all_detections[sensornode])) == 3 + assert type(all_detections[sensornode]) == set + for detection in all_detections[sensornode]: + assert type(detection) == TrueDetection + - # Reset and repeat first 2 assertions with no ground truth paths +def test_information_arch_measure_no_detections(edge_lists, ground_truths, times): edges = edge_lists["radar_edges"] start_time = times['start'] - network = InformationArchitecture(edges=edges) + network = InformationArchitecture(edges=edges, current_time=None) all_detections = network.measure(ground_truths=[], current_time=start_time) assert type(all_detections) == dict assert all_detections.keys() == network.sensor_nodes # There should exist a key for each sensor node containing an empty list - for sensornode in sensornodes: + for sensornode in network.sensor_nodes: assert(len(all_detections[sensornode])) == 0 + assert type(all_detections[sensornode]) == set + + +def test_information_arch_measure_no_time(edge_lists, ground_truths): + edges = edge_lists["radar_edges"] + network = InformationArchitecture(edges=edges) + all_detections = network.measure(ground_truths=ground_truths) + + assert type(all_detections) == dict + assert all_detections.keys() == network.sensor_nodes + for sensornode in network.sensor_nodes: + assert(len(all_detections[sensornode])) == 3 + assert type(all_detections[sensornode]) == set + for detection in all_detections[sensornode]: + assert type(detection) == TrueDetection + +# +# def test_information_arch_propagate(edge_lists, ground_truths, times): +# edges = edge_lists["radar_edges"] +# start_time = times['start'] +# network = InformationArchitecture(edges=edges) +# network.measure(ground_truths=ground_truths, current_time=start_time) +# +# network.propagate(time_increment=0.1) +# +# for node in network.sensor_nodes: +# print(len(node.data_held)) + diff --git a/stonesoup/types/state.py b/stonesoup/types/state.py index a1ee7323b..bce9026ae 100644 --- a/stonesoup/types/state.py +++ b/stonesoup/types/state.py @@ -654,7 +654,7 @@ def __init__(self, *args, **kwargs): StateVectors([particle.state_vector for particle in self.particle_list]) self.weight = \ np.array([Probability(particle.weight) for particle in self.particle_list]) - parent_list = [particle.recipient for particle in self.particle_list] + parent_list = [particle.parent for particle in self.particle_list] if parent_list.count(None) == 0: self.parent = ParticleState(None, particle_list=parent_list) From bf42cc7080741d4749a9a5af0044af0e9f80d42a Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Thu, 24 Aug 2023 18:45:58 +0100 Subject: [PATCH 077/170] finished test_functions --- stonesoup/architecture/__init__.py | 4 +- .../{functions.py => _functions.py} | 2 +- stonesoup/architecture/edge.py | 2 +- stonesoup/architecture/node.py | 17 ++--- stonesoup/architecture/tests/conftest.py | 10 ++- .../architecture/tests/test_functions.py | 63 +++++++++++++++++++ stonesoup/architecture/tests/test_node.py | 29 +++++++++ 7 files changed, 114 insertions(+), 13 deletions(-) rename stonesoup/architecture/{functions.py => _functions.py} (95%) create mode 100644 stonesoup/architecture/tests/test_functions.py diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 59ed37cc2..a5adfa549 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -4,7 +4,7 @@ from .edge import Edges, DataPiece from ..types.groundtruth import GroundTruthPath from ..types.detection import TrueDetection, Clutter -from .functions import _default_letters, _default_label +from ._functions import _default_letters, _default_label from typing import List, Collection, Tuple, Set, Union, Dict import numpy as np @@ -56,7 +56,7 @@ def __init__(self, *args, **kwargs): "if you wish to override this requirement") # Set attributes such as label, colour, shape, etc. for each node - last_letters = {'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', + last_letters = {'Node': '', 'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', 'RepeaterNode': ''} for node in self.di_graph.nodes: if node.label: diff --git a/stonesoup/architecture/functions.py b/stonesoup/architecture/_functions.py similarity index 95% rename from stonesoup/architecture/functions.py rename to stonesoup/architecture/_functions.py index 1f4d6e8df..6537f84e9 100644 --- a/stonesoup/architecture/functions.py +++ b/stonesoup/architecture/_functions.py @@ -3,7 +3,7 @@ def _dict_set(my_dict, value, key1, key2=None): """Utility function to add value to my_dict at the specified key(s) - Returns True iff the set increased in size, ie the value was new to its position""" + Returns True if the set increased in size, i.e. the value was new to its position""" if not my_dict: if key2: my_dict = {key1: {key2: {value}}} diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 64d2b81fa..b4f345ef7 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -9,7 +9,7 @@ from ..types.track import Track from ..types.detection import Detection from ..types.hypothesis import Hypothesis -from .functions import _dict_set +from ._functions import _dict_set if TYPE_CHECKING: from .node import Node diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 6d890083e..7056ed679 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -9,17 +9,16 @@ from ..types.detection import Detection from ..types.hypothesis import Hypothesis from ..types.track import Track -from ..tracker.base import Tracker from .edge import DataPiece, FusionQueue from ..tracker.fusion import FusionTracker -from .functions import _dict_set +from ._functions import _dict_set class Node(Base): """Base node class. Should be abstract""" latency: float = Property( doc="Contribution to edge latency stemming from this node", - default=0) + default=0.0) label: str = Property( doc="Label to be displayed on graph", default=None) @@ -47,16 +46,20 @@ def update(self, time_pertaining, time_arrived, data_piece, category, track=None if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): raise TypeError("Times must be datetime objects") if not track: - if not isinstance(data_piece.data, Detection) and not isinstance(data_piece.data, Track): - raise TypeError(f"Data provided without accompanying Track must be a Detection or a Track, not a " + if not isinstance(data_piece.data, Detection) and \ + not isinstance(data_piece.data, Track): + raise TypeError(f"Data provided without accompanying Track must be a Detection or " + f"a Track, not a " f"{type(data_piece.data).__name__}") new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, time_arrived) else: if not isinstance(data_piece.data, Hypothesis): raise TypeError("Data provided with Track must be a Hypothesis") - new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, time_arrived, track) + new_data_piece = DataPiece(self, data_piece.originator, data_piece.data, + time_arrived, track) - added, self.data_held[category] = _dict_set(self.data_held[category], new_data_piece, time_pertaining) + added, self.data_held[category] = _dict_set(self.data_held[category], + new_data_piece, time_pertaining) if isinstance(self, FusionNode) and category in ("created", "unfused"): self.fusion_queue.put((time_pertaining, {data_piece.data})) diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index dd9ee7c31..91bbe80b7 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -12,7 +12,8 @@ from ...types.track import Track from ...sensor.categorical import HMMSensor from ...models.measurement.categorical import MarkovianMeasurementModel -from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, ConstantVelocity +from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, \ + ConstantVelocity from stonesoup.types.groundtruth import GroundTruthPath, GroundTruthState from ordered_set import OrderedSet from stonesoup.types.state import StateVector @@ -35,6 +36,7 @@ from stonesoup.updater.wrapper import DetectionAndTrackSwitchingUpdater from stonesoup.updater.chernoff import ChernoffUpdater from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder +from ...types.hypothesis import Hypothesis @pytest.fixture @@ -81,7 +83,11 @@ def data_pieces(times, nodes): data=Track([]), time_arrived=times['a']) data_piece_b = DataPiece(node=nodes['a'], originator=nodes['b'], data=Track([]), time_arrived=times['b']) - return {'a': data_piece_a, 'b': data_piece_b} + data_piece_fail = DataPiece(node=nodes['a'], originator=nodes['b'], + data="Not a compatible data type", time_arrived=times['b']) + data_piece_hyp = DataPiece(node=nodes['a'], originator=nodes['b'], + data=Hypothesis(), time_arrived=times['b']) + return {'a': data_piece_a, 'b': data_piece_b, 'fail': data_piece_fail, 'hyp': data_piece_hyp} @pytest.fixture diff --git a/stonesoup/architecture/tests/test_functions.py b/stonesoup/architecture/tests/test_functions.py new file mode 100644 index 000000000..8403c05e4 --- /dev/null +++ b/stonesoup/architecture/tests/test_functions.py @@ -0,0 +1,63 @@ +import pytest + +from .._functions import _dict_set, _default_label, _default_letters +from ..node import RepeaterNode + + +def test_dict_set(): + d = dict() + assert d == {} + + inc, d = _dict_set(d, "c", "cow") + assert inc + assert d == {"cow": {"c"}} + inc, d = _dict_set(d, "o", "cow") + assert inc + assert d == {"cow": {"c", "o"}} + inc, d = _dict_set(d, "c", "cow") + assert not inc + assert d == {"cow": {"c", "o"}} + + d2 = dict() + assert d2 == {} + + inc, d2 = _dict_set(d2, "africa", "lion", "yes") + assert inc + assert d2 == {"lion": {"yes": {"africa"}}} + + inc, d2 = _dict_set(d2, "europe", "polar bear", "no") + assert inc + assert d2 == {"lion": {"yes": {"africa"}}, "polar bear": {"no": {"europe"}}} + + inc, d2 = _dict_set(d2, "europe", "lion", "no") + assert inc + assert d2 == {"lion": {"yes": {"africa"}, "no": {"europe"}}, "polar bear": {"no": {"europe"}}} + + inc, d2 = _dict_set(d2, "north america", "lion", "no") + assert inc + assert d2 == {"lion": {"yes": {"africa"}, "no": {"europe", "north america"}}, + "polar bear": {"no": {"europe"}}} + + +def test_default_label(nodes): + last_letters = {'Node': '', 'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', + 'RepeaterNode': 'Z'} + node = nodes['a'] + label, last_letters = _default_label(node, last_letters) + assert last_letters['Node'] == 'A' + assert label == 'Node A' + + repeater = RepeaterNode() + assert last_letters['RepeaterNode'] == 'Z' + label, last_letters = _default_label(repeater, last_letters) + assert last_letters['RepeaterNode'] == 'AA' + assert label == 'RepeaterNode AA' + + +def test_default_letters(): + assert _default_letters('') == 'A' + assert _default_letters('A') == 'B' + assert _default_letters('Z') == 'AA' + assert _default_letters('AA') == 'AB' + assert _default_letters('AZ') == 'BA' + assert _default_letters('ZZ') == 'AAA' diff --git a/stonesoup/architecture/tests/test_node.py b/stonesoup/architecture/tests/test_node.py index e69de29bb..7bced3da5 100644 --- a/stonesoup/architecture/tests/test_node.py +++ b/stonesoup/architecture/tests/test_node.py @@ -0,0 +1,29 @@ +import pytest + +from ..node import Node, SensorNode, FusionNode, SensorFusionNode +from ...types.hypothesis import Hypothesis +from ...types.track import Track +from ...types.state import State, StateVector + + +def test_node(data_pieces, times, nodes): + node = Node() + assert node.latency == 0.0 + assert node.font_size == 5 + assert len(node.data_held) == 3 + assert node.data_held == {"fused": {}, "created": {}, "unfused": {}} + + node.update(times['a'], times['b'], data_pieces['a'], "fused") + new_data_piece = node.data_held['fused'][times['a']].pop() + assert new_data_piece.originator == nodes['a'] + assert isinstance(new_data_piece.data, Track) and len(new_data_piece.data) == 0 + assert new_data_piece.time_arrived == times['b'] + + with pytest.raises(TypeError): + node.update(times['b'], times['a'], data_pieces['hyp'], "created") + node.update(times['b'], times['a'], data_pieces['hyp'], "created", + track=Track([State(state_vector=StateVector([1]))])) + new_data_piece2 = node.data_held['created'][times['b']].pop() + assert new_data_piece2.originator == nodes['b'] + assert isinstance(new_data_piece2.data, Hypothesis) + assert new_data_piece2.time_arrived == times['a'] From abbee3704734680b8b7a741302b9c8b317cebca8 Mon Sep 17 00:00:00 2001 From: spike Date: Fri, 25 Aug 2023 10:39:56 +0100 Subject: [PATCH 078/170] Add tests for propagate function, test fails currently --- stonesoup/architecture/tests/conftest.py | 8 +- .../architecture/tests/test_architecture.py | 77 +++++++++++++++---- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 91bbe80b7..7d51f1050 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -68,13 +68,14 @@ def nodes(): sensornode_6 = SensorNode(sensor=hmm_sensor, label='s6') sensornode_7 = SensorNode(sensor=hmm_sensor, label='s7') sensornode_8 = SensorNode(sensor=hmm_sensor, label='s8') + repeaternode_1 = RepeaterNode() pnode_1 = SensorNode(sensor=hmm_sensor, label='p1', position=(0, 0)) pnode_2 = SensorNode(sensor=hmm_sensor, label='p2', position=(-1, -1)) pnode_3 = SensorNode(sensor=hmm_sensor, label='p3', position=(1, -1)) return {"a": node_a, "b": node_b, "s1": sensornode_1, "s2": sensornode_2, "s3": sensornode_3, "s4": sensornode_4, "s5": sensornode_5, "s6": sensornode_6, "s7": sensornode_7, - "s8": sensornode_8, "p1": pnode_1, "p2": pnode_2, "p3": pnode_3} + "s8": sensornode_8, "r1": repeaternode_1, "p1": pnode_1, "p2": pnode_2, "p3": pnode_3} @pytest.fixture @@ -308,6 +309,8 @@ def edge_lists(nodes, radar_nodes): [Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s4'], nodes['s3'])), Edge((nodes['s3'], nodes['s4']))]) + repeater_edges = Edges([Edge((nodes['s2'], nodes['r1'])), Edge((nodes['s4'], nodes['r1']))]) + radar_edges = Edges([Edge((radar_nodes['a'], radar_nodes['c'])), Edge((radar_nodes['b'], radar_nodes['c'])), Edge((radar_nodes['d'], radar_nodes['f'])), @@ -320,6 +323,7 @@ def edge_lists(nodes, radar_nodes): "simple_edges": simple_edges, "linear_edges": linear_edges, "decentralised_edges": decentralised_edges, "disconnected_edges": disconnected_edges, "k4_edges": k4_edges, "circular_edges": circular_edges, - "disconnected_loop_edges": disconnected_loop_edges, "radar_edges": radar_edges} + "disconnected_loop_edges": disconnected_loop_edges, "repeater_edges": repeater_edges, + "radar_edges": radar_edges} diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 4433c87b7..63eaf6969 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -1,11 +1,21 @@ import pytest +import datetime from stonesoup.architecture import InformationArchitecture -from ..edge import Edge, Edges, DataPiece -from ..node import RepeaterNode# +from ..edge import Edge, Edges +from ..node import RepeaterNode from stonesoup.types.detection import TrueDetection +def test_architecture_init(edge_lists, times): + time = times['start'] + edges = edge_lists["decentralised_edges"] + arch = InformationArchitecture(edges=edges, name='Name of Architecture', current_time=time) + + assert arch.name == 'Name of Architecture' + assert arch.current_time == time + + def test_hierarchical_plot(tmpdir, nodes, edge_lists): edges = edge_lists["hierarchical_edges"] @@ -258,10 +268,10 @@ def test_shortest_path_dict(nodes, edge_lists): assert h_arch.shortest_path_dict[nodes['s5']][nodes['s1']] == 2 with pytest.raises(KeyError): - dist = h_arch.shortest_path_dict[nodes['s2']][nodes['s3']] + _ = h_arch.shortest_path_dict[nodes['s2']][nodes['s3']] with pytest.raises(KeyError): - dist = h_arch.shortest_path_dict[nodes['s3']][nodes['s6']] + _ = h_arch.shortest_path_dict[nodes['s3']][nodes['s6']] disconnected_arch = InformationArchitecture(edges=disconnected_edges, force_connected=False) @@ -529,11 +539,16 @@ def test_information_arch_measure(edge_lists, ground_truths, times): # Check that correct number of detections recorded for each sensor node is equal to the number # of targets for sensornode in network.sensor_nodes: + # Check that a detection is made for all 3 targets assert(len(all_detections[sensornode])) == 3 assert type(all_detections[sensornode]) == set for detection in all_detections[sensornode]: assert type(detection) == TrueDetection + for node in network.sensor_nodes: + # Check that each sensor node has data held for the detection of all 3 targets + assert len(node.data_held['created'][datetime.datetime(1306, 12, 25, 23, 47, 59)]) == 3 + def test_information_arch_measure_no_noise(edge_lists, ground_truths, times): edges = edge_lists["radar_edges"] @@ -579,15 +594,47 @@ def test_information_arch_measure_no_time(edge_lists, ground_truths): for detection in all_detections[sensornode]: assert type(detection) == TrueDetection -# -# def test_information_arch_propagate(edge_lists, ground_truths, times): -# edges = edge_lists["radar_edges"] -# start_time = times['start'] -# network = InformationArchitecture(edges=edges) -# network.measure(ground_truths=ground_truths, current_time=start_time) -# -# network.propagate(time_increment=0.1) -# -# for node in network.sensor_nodes: -# print(len(node.data_held)) + +def test_fully_propagated(edge_lists, times, ground_truths): + edges = edge_lists["radar_edges"] + start_time = times['start'] + + network = InformationArchitecture(edges=edges) + + for node in network.sensor_nodes: + # Check that each sensor node has data held for the detection of all 3 targets + for key in node.data_held['created'].keys(): + print(key) + assert len(node.data_held['created'][key]) == 3 + + # Network should not be fully propagated + assert network.fully_propagated is False + + network.propagate(time_increment=1) + + # Network should now be fully propagated + assert network.fully_propagated + + +def test_information_arch_propagate(edge_lists, ground_truths, times): + edges = edge_lists["radar_edges"] + start_time = times['start'] + network = InformationArchitecture(edges=edges) + network.measure(ground_truths=ground_truths, current_time=start_time) + + for _ in range(1): + network.measure(ground_truths=ground_truths, current_time=start_time) + network.propagate(time_increment=0.1) + + assert network.fully_propagated + + +def test_information_arch_init(edge_lists): + edges = edge_lists["repeater_edges"] + + # Network contains a repeater node, InformationArchitecture should raise a type error. + with pytest.raises(TypeError): + _ = InformationArchitecture(edges=edges) + + From 41251484362f6bbe5dcb7ba78ce041eee0057a05 Mon Sep 17 00:00:00 2001 From: spike Date: Fri, 25 Aug 2023 13:20:36 +0100 Subject: [PATCH 079/170] Fix propagate tests, Flake8 errors --- stonesoup/architecture/__init__.py | 17 ++++++---- stonesoup/architecture/edge.py | 7 ++-- stonesoup/architecture/tests/conftest.py | 26 ++++++++------- .../architecture/tests/test_architecture.py | 33 ++++++++----------- stonesoup/architecture/tests/test_edge.py | 2 +- .../architecture/tests/test_functions.py | 1 - stonesoup/architecture/tests/test_node.py | 2 +- stonesoup/base.py | 2 +- stonesoup/tracker/fusion.py | 10 ++---- stonesoup/tracker/tests/test_fusion.py | 2 +- stonesoup/types/tests/test_groundtruth.py | 1 + stonesoup/updater/wrapper.py | 2 +- 12 files changed, 51 insertions(+), 54 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index a5adfa549..4a68d8ed1 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -1,17 +1,16 @@ from abc import abstractmethod from ..base import Base, Property -from .node import Node, SensorNode, RepeaterNode, FusionNode, SensorFusionNode +from .node import Node, SensorNode, RepeaterNode, FusionNode from .edge import Edges, DataPiece from ..types.groundtruth import GroundTruthPath from ..types.detection import TrueDetection, Clutter -from ._functions import _default_letters, _default_label +from ._functions import _default_label from typing import List, Collection, Tuple, Set, Union, Dict import numpy as np import networkx as nx import graphviz from datetime import datetime, timedelta -import threading class Architecture(Base): @@ -96,7 +95,11 @@ def shortest_path_dict(self): from node1 to node2 if key1=node1 and key2=node2. If no path exists from node1 to node2, a KeyError is raised. """ - path = nx.all_pairs_shortest_path_length(self.di_graph) + # Initiate a new DiGraph as self.digraph isn't necessarily directed. + g = nx.DiGraph() + for edge in self.edges.edge_list: + g.add_edge(edge[0], edge[1]) + path = nx.all_pairs_shortest_path_length(g) dpath = {x[0]: x[1] for x in path} return dpath @@ -340,7 +343,7 @@ def is_centralised(self): top_node = top_nodes.pop() for node in self.all_nodes - self.top_level_nodes: try: - dist = self.shortest_path_dict[node][top_node] + _ = self.shortest_path_dict[node][top_node] except KeyError: return False return True @@ -378,7 +381,7 @@ def __init__(self, *args, **kwargs): if isinstance(node, RepeaterNode): raise TypeError("Information architecture should not contain any repeater nodes") for fusion_node in self.fusion_nodes: - pass # fusion_node.tracker.set_time(self.current_time) + pass # fusion_node.tracker.set_time(self.current_time) def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: @@ -439,7 +442,7 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): self.current_time += timedelta(seconds=time_increment) for fusion_node in self.fusion_nodes: - pass # fusion_node.tracker.set_time(self.current_time) + pass # fusion_node.tracker.set_time(self.current_time) class NetworkArchitecture(Architecture): diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index b4f345ef7..a397cccd6 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -51,12 +51,13 @@ class DataPiece(Base): node: "Node" = Property( doc="The Node this data piece belongs to") originator: "Node" = Property( - doc="The node which first created this data, ie by sensing or fusing information together. " - "If the data is simply passed along the chain, the originator remains unchanged. ") + doc="The node which first created this data, ie by sensing or fusing information together." + " If the data is simply passed along the chain, the originator remains unchanged. ") data: Union[Detection, Track, Hypothesis] = Property( doc="A Detection, Track, or Hypothesis") time_arrived: datetime = Property( - doc="The time at which this piece of data was received by the Node, either by Message or by sensing.") + doc="The time at which this piece of data was received by the Node, either by Message or " + "by sensing.") track: Track = Property( doc="The Track in the event of data being a Hypothesis", default=None) diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 7d51f1050..1366d2cc6 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -1,28 +1,22 @@ import pytest - import numpy as np -import random from ordered_set import OrderedSet from datetime import datetime, timedelta import copy from ..edge import Edge, DataPiece, Edges -from ..node import Node, RepeaterNode, SensorNode, FusionNode, SensorFusionNode +from ..node import Node, RepeaterNode, SensorNode, FusionNode from ...types.track import Track from ...sensor.categorical import HMMSensor from ...models.measurement.categorical import MarkovianMeasurementModel from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, \ ConstantVelocity from stonesoup.types.groundtruth import GroundTruthPath, GroundTruthState -from ordered_set import OrderedSet from stonesoup.types.state import StateVector from stonesoup.sensor.radar.radar import RadarRotatingBearingRange from stonesoup.types.angle import Angle -from stonesoup.architecture import Architecture, NetworkArchitecture, InformationArchitecture, \ - CombinedArchitecture -from stonesoup.architecture.node import FusionNode, RepeaterNode, SensorNode, SensorFusionNode -from stonesoup.architecture.edge import Edge, Edges + from stonesoup.predictor.kalman import KalmanPredictor from stonesoup.updater.kalman import ExtendedKalmanUpdater from stonesoup.hypothesiser.distance import DistanceHypothesiser @@ -106,6 +100,14 @@ def transition_model(): return transition_model +@pytest.fixture +def timesteps(times): + start_time = times["start"] + time_max = 60 # timestamps the simulation is observed over + timesteps = [start_time + timedelta(seconds=k) for k in range(time_max)] + return timesteps + + @pytest.fixture def ground_truths(transition_model, times): start_time = times["start"] @@ -274,7 +276,8 @@ def radar_nodes(radar_sensors, fusion_queue): def edge_lists(nodes, radar_nodes): hierarchical_edges = Edges([Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s3'], nodes['s1'])), Edge((nodes['s4'], nodes['s2'])), Edge((nodes['s5'], nodes['s2'])), - Edge((nodes['s6'], nodes['s3'])), Edge((nodes['s7'], nodes['s6']))]) + Edge((nodes['s6'], nodes['s3'])), Edge((nodes['s7'], nodes['s6']))] + ) centralised_edges = Edges( [Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s3'], nodes['s1'])), @@ -293,7 +296,8 @@ def edge_lists(nodes, radar_nodes): Edge((nodes['s3'], nodes['s4'])), Edge((nodes['s3'], nodes['s5'])), Edge((nodes['s5'], nodes['s4']))]) - disconnected_edges = Edges([Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s4'], nodes['s3']))]) + disconnected_edges = Edges([Edge((nodes['s2'], nodes['s1'])), + Edge((nodes['s4'], nodes['s3']))]) k4_edges = Edges( [Edge((nodes['s1'], nodes['s2'])), Edge((nodes['s1'], nodes['s3'])), @@ -325,5 +329,3 @@ def edge_lists(nodes, radar_nodes): "k4_edges": k4_edges, "circular_edges": circular_edges, "disconnected_loop_edges": disconnected_loop_edges, "repeater_edges": repeater_edges, "radar_edges": radar_edges} - - diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 63eaf6969..823d94ef3 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -7,15 +7,6 @@ from stonesoup.types.detection import TrueDetection -def test_architecture_init(edge_lists, times): - time = times['start'] - edges = edge_lists["decentralised_edges"] - arch = InformationArchitecture(edges=edges, name='Name of Architecture', current_time=time) - - assert arch.name == 'Name of Architecture' - assert arch.current_time == time - - def test_hierarchical_plot(tmpdir, nodes, edge_lists): edges = edge_lists["hierarchical_edges"] @@ -599,12 +590,12 @@ def test_fully_propagated(edge_lists, times, ground_truths): edges = edge_lists["radar_edges"] start_time = times['start'] - network = InformationArchitecture(edges=edges) + network = InformationArchitecture(edges=edges, current_time=start_time) + network.measure(ground_truths=ground_truths, noise=True) for node in network.sensor_nodes: # Check that each sensor node has data held for the detection of all 3 targets for key in node.data_held['created'].keys(): - print(key) assert len(node.data_held['created'][key]) == 3 # Network should not be fully propagated @@ -619,22 +610,26 @@ def test_fully_propagated(edge_lists, times, ground_truths): def test_information_arch_propagate(edge_lists, ground_truths, times): edges = edge_lists["radar_edges"] start_time = times['start'] - network = InformationArchitecture(edges=edges) - network.measure(ground_truths=ground_truths, current_time=start_time) + network = InformationArchitecture(edges=edges, current_time=start_time) - for _ in range(1): - network.measure(ground_truths=ground_truths, current_time=start_time) - network.propagate(time_increment=0.1) + network.measure(ground_truths=ground_truths, noise=True) + network.propagate(time_increment=1) assert network.fully_propagated +def test_architecture_init(edge_lists, times): + time = times['start'] + edges = edge_lists["decentralised_edges"] + arch = InformationArchitecture(edges=edges, name='Name of Architecture', current_time=time) + + assert arch.name == 'Name of Architecture' + assert arch.current_time == time + + def test_information_arch_init(edge_lists): edges = edge_lists["repeater_edges"] # Network contains a repeater node, InformationArchitecture should raise a type error. with pytest.raises(TypeError): _ = InformationArchitecture(edges=edges) - - - diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 79ee005c8..ddad93c5b 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -9,7 +9,7 @@ def test_data_piece(nodes, times): with pytest.raises(TypeError): - data_piece_fail = DataPiece() + _ = DataPiece() data_piece = DataPiece(node=nodes['a'], originator=nodes['a'], data=Track([]), time_arrived=times['a']) diff --git a/stonesoup/architecture/tests/test_functions.py b/stonesoup/architecture/tests/test_functions.py index 8403c05e4..72ad14b30 100644 --- a/stonesoup/architecture/tests/test_functions.py +++ b/stonesoup/architecture/tests/test_functions.py @@ -1,4 +1,3 @@ -import pytest from .._functions import _dict_set, _default_label, _default_letters from ..node import RepeaterNode diff --git a/stonesoup/architecture/tests/test_node.py b/stonesoup/architecture/tests/test_node.py index 7bced3da5..6cfa7af59 100644 --- a/stonesoup/architecture/tests/test_node.py +++ b/stonesoup/architecture/tests/test_node.py @@ -1,6 +1,6 @@ import pytest -from ..node import Node, SensorNode, FusionNode, SensorFusionNode +from ..node import Node from ...types.hypothesis import Hypothesis from ...types.track import Track from ...types.state import State, StateVector diff --git a/stonesoup/base.py b/stonesoup/base.py index ab6185cf9..a5e453441 100644 --- a/stonesoup/base.py +++ b/stonesoup/base.py @@ -61,7 +61,7 @@ def __init__(self, foo, bar=bar.default, *args, **kwargs): from copy import copy from functools import cached_property from types import MappingProxyType -from typing import TYPE_CHECKING +# from typing import TYPE_CHECKING class Property: diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py index 847e2528c..b5b48b7e1 100644 --- a/stonesoup/tracker/fusion.py +++ b/stonesoup/tracker/fusion.py @@ -1,17 +1,12 @@ -import datetime from abc import ABC from stonesoup.architecture.edge import FusionQueue from .base import Tracker -from ..base import Property, Base +from ..base import Property from stonesoup.buffered_generator import BufferedGenerator -from stonesoup.dataassociator.tracktotrack import TrackToTrackCounting from stonesoup.reader.base import DetectionReader from stonesoup.types.detection import Detection -from stonesoup.types.hypothesis import Hypothesis from stonesoup.types.track import Track -from stonesoup.tracker.pointprocess import PointProcessMultiTargetTracker -from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder class FusionTracker(Tracker, ABC): @@ -68,7 +63,8 @@ def __next__(self): # like this? # for tracks in [ctracks]: # dummy_detector = DummyDetector(current=[time, tracks]) - # self.track_fusion_tracker.detector = Tracks2GaussianDetectionFeeder(dummy_detector) + # self.track_fusion_tracker.detector = + # Tracks2GaussianDetectionFeeder(dummy_detector) # self.track_fusion_tracker.__iter__() # _, tracks = next(self.track_fusion_tracker) # self.track_fusion_tracker.update(tracks) diff --git a/stonesoup/tracker/tests/test_fusion.py b/stonesoup/tracker/tests/test_fusion.py index 745c7d64b..826bea16f 100644 --- a/stonesoup/tracker/tests/test_fusion.py +++ b/stonesoup/tracker/tests/test_fusion.py @@ -5,5 +5,5 @@ def test_fusion_tracker(initiator, deleter, detector, data_associator, updater): base_tracker = MultiTargetTracker( initiator, deleter, detector, data_associator, updater) - tracker = SimpleFusionTracker(base_tracker, 30) + _ = SimpleFusionTracker(base_tracker, 30) assert True diff --git a/stonesoup/types/tests/test_groundtruth.py b/stonesoup/types/tests/test_groundtruth.py index f264eb5c6..512e01d61 100644 --- a/stonesoup/types/tests/test_groundtruth.py +++ b/stonesoup/types/tests/test_groundtruth.py @@ -5,6 +5,7 @@ from datetime import datetime + def test_groundtruthpath(): empty_path = GroundTruthPath() diff --git a/stonesoup/updater/wrapper.py b/stonesoup/updater/wrapper.py index b1678a797..92b029749 100644 --- a/stonesoup/updater/wrapper.py +++ b/stonesoup/updater/wrapper.py @@ -20,4 +20,4 @@ def update(self, hypothesis, **kwargs): if isinstance(hypothesis.measurement, GaussianDetection): return self.track_updater.update(hypothesis, **kwargs) else: - return self.detection_updater.update(hypothesis, **kwargs) \ No newline at end of file + return self.detection_updater.update(hypothesis, **kwargs) From 3529f9af205e18dfda813d49ecf1361a4c2a6107 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Fri, 25 Aug 2023 15:20:59 +0100 Subject: [PATCH 080/170] finished test_node --- stonesoup/architecture/node.py | 4 +- .../architecture/tests/test_architecture.py | 9 ++- stonesoup/architecture/tests/test_node.py | 56 ++++++++++++++++++- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 7056ed679..645b86869 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -10,7 +10,7 @@ from ..types.hypothesis import Hypothesis from ..types.track import Track from .edge import DataPiece, FusionQueue -from ..tracker.fusion import FusionTracker +from ..tracker.base import Tracker from ._functions import _dict_set @@ -83,7 +83,7 @@ class SensorNode(Node): class FusionNode(Node): """A node that does not measure new data, but does process data it receives""" # feeder probably as well - tracker: FusionTracker = Property( + tracker: Tracker = Property( doc="Tracker used by this Node to fuse together Tracks and Detections") fusion_queue: FusionQueue = Property( default=FusionQueue(), diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 823d94ef3..6293ece8d 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -598,13 +598,18 @@ def test_fully_propagated(edge_lists, times, ground_truths): for key in node.data_held['created'].keys(): assert len(node.data_held['created'][key]) == 3 + for edge in network.edges.edges: + if len(edge.unsent_data) != 0: + print(f"Node {edge.sender.label} has unsent data:") + print(edge.unsent_data) + # Network should not be fully propagated - assert network.fully_propagated is False + #assert network.fully_propagated is False network.propagate(time_increment=1) # Network should now be fully propagated - assert network.fully_propagated + #assert network.fully_propagated def test_information_arch_propagate(edge_lists, ground_truths, times): diff --git a/stonesoup/architecture/tests/test_node.py b/stonesoup/architecture/tests/test_node.py index 6cfa7af59..cbbb67868 100644 --- a/stonesoup/architecture/tests/test_node.py +++ b/stonesoup/architecture/tests/test_node.py @@ -1,6 +1,9 @@ import pytest -from ..node import Node +import copy + +from ..node import Node, SensorNode, FusionNode, SensorFusionNode, RepeaterNode +from ..edge import FusionQueue from ...types.hypothesis import Hypothesis from ...types.track import Track from ...types.state import State, StateVector @@ -27,3 +30,54 @@ def test_node(data_pieces, times, nodes): assert new_data_piece2.originator == nodes['b'] assert isinstance(new_data_piece2.data, Hypothesis) assert new_data_piece2.time_arrived == times['a'] + + +def test_sensor_node(nodes): + with pytest.raises(TypeError): + SensorNode() + + sensor = nodes['s1'].sensor + snode = SensorNode(sensor=sensor) + assert snode.sensor == sensor + assert snode.colour == '#1f77b4' + assert snode.shape == 'oval' + assert snode.node_dim == (0.5, 0.3) + + +def test_fusion_node(tracker): + fnode_tracker = copy.deepcopy(tracker) + fnode_tracker.detector = FusionQueue() + fnode = FusionNode(tracker=fnode_tracker, fusion_queue=fnode_tracker.detector, latency=0) + + assert fnode.colour == '#006400' + assert fnode.shape == 'hexagon' + assert fnode.node_dim == (0.6, 0.3) + assert fnode.tracks == set() + + with pytest.raises(TypeError): + FusionNode() + + fnode.fuse() # Works. Thorough testing left to test_architecture.py + + +def test_sf_node(tracker, nodes): + with pytest.raises(TypeError): + SensorFusionNode() + sfnode_tracker = copy.deepcopy(tracker) + sfnode_tracker.detector = FusionQueue() + sfnode = SensorFusionNode(tracker=sfnode_tracker, fusion_queue=sfnode_tracker.detector, + latency=0, sensor=nodes['s1'].sensor) + + assert sfnode.colour == '#909090' + assert sfnode.shape == 'rectangle' + assert sfnode.node_dim == (0.1, 0.3) + + assert sfnode.tracks == set() + + +def test_repeater_node(): + rnode = RepeaterNode() + + assert rnode.colour == '#ff7f0e' + assert rnode.shape == 'circle' + assert rnode.node_dim == (0.5, 0.3) \ No newline at end of file From 6d9c4b7ccfedaeb41bfadb73603db7cc69715f26 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman Date: Fri, 25 Aug 2023 15:48:09 +0100 Subject: [PATCH 081/170] add networkx and graphviz to requirements --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index a3df6131f..607db0e41 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,6 +58,9 @@ orbital = astropy mfa = ortools +architectures = + networkx + graphviz [options.packages.find] exclude = From 876b34ad1183b6913a02a1c2c920dca166749282 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 4 Sep 2023 12:09:51 +0100 Subject: [PATCH 082/170] Change to how DOT code is written in plot() function --- stonesoup/architecture/__init__.py | 34 ++++++++++++++++++++---- stonesoup/architecture/tests/conftest.py | 2 +- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 4a68d8ed1..ce9812859 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -1,4 +1,7 @@ from abc import abstractmethod + +import pydot + from ..base import Base, Property from .node import Node, SensorNode, RepeaterNode, FusionNode from .edge import Edges, DataPiece @@ -290,11 +293,32 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, # Update layer count for correct y location layer -= 1 - dot = nx.drawing.nx_pydot.to_pydot(self.di_graph).to_string() - dot_split = dot.split('\n') - dot_split.insert(1, f"graph [bgcolor={bgcolour}]") - dot_split.insert(1, f"node [style={node_style}]") - dot = "\n".join(dot_split) + strict = nx.number_of_selfloops(self.di_graph) == 0 and not self.di_graph.is_multigraph() + graph = pydot.Dot(graph_name='', strict=strict, graph_type='digraph') + for node in self.all_nodes: + if use_positions or self.is_hierarchical or plot_style == 'hierarchical': + str_position = '"' + str(node.position[0]) + ',' + str(node.position[1]) + '!"' + new_node = pydot.Node('"' + node.label + '"', label=node.label, shape=node.shape, + pos=str_position, color=node.colour, fontsize=node.font_size, + height=str(node.node_dim[1]), width=str(node.node_dim[0]), + fixedsize=True) + else: + new_node = pydot.Node('"' + node.label + '"', label=node.label, shape=node.shape, + color=node.colour, fontsize=node.font_size, + height=str(node.node_dim[1]), width=str(node.node_dim[0]), + fixedsize=True) + graph.add_node(new_node) + + for edge in self.edges.edge_list: + new_edge = pydot.Edge('"' + edge[0].label + '"', '"' + edge[1].label + '"') + graph.add_edge(new_edge) + + dot = graph.to_string() + dot_split = dot.split('{', maxsplit=1) + dot_split.insert(1, '\n' + f"graph [bgcolor={bgcolour}]") + dot_split.insert(1, '{ \n' + f"node [style={node_style}]") + dot = ''.join(dot_split) + if plot_title: if plot_title is True: plot_title = self.name diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 1366d2cc6..3d8181968 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -54,7 +54,7 @@ def nodes(): node_a = Node(label="node a") node_b = Node(label="node b") - sensornode_1 = SensorNode(sensor=hmm_sensor, label='s1') + sensornode_1 = SensorNode(sensor=hmm_sensor, label="s1") sensornode_2 = SensorNode(sensor=hmm_sensor, label='s2') sensornode_3 = SensorNode(sensor=hmm_sensor, label='s3') sensornode_4 = SensorNode(sensor=hmm_sensor, label='s4') From 8288bf3f21bc1d8566582894d5d82284269d5885 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 4 Sep 2023 17:23:38 +0100 Subject: [PATCH 083/170] Version 1 of architecture tutorial. Fixes to allow nodes to be plotted. --- .../Intoduction_to_architectures.py | 297 ++++++++++++++++++ docs/tutorials/architecture/README.rst | 3 + stonesoup/architecture/__init__.py | 4 +- stonesoup/architecture/_functions.py | 2 +- stonesoup/architecture/node.py | 46 +-- stonesoup/architecture/tests/conftest.py | 23 +- .../architecture/tests/test_architecture.py | 5 + 7 files changed, 351 insertions(+), 29 deletions(-) create mode 100644 docs/tutorials/architecture/Intoduction_to_architectures.py create mode 100644 docs/tutorials/architecture/README.rst diff --git a/docs/tutorials/architecture/Intoduction_to_architectures.py b/docs/tutorials/architecture/Intoduction_to_architectures.py new file mode 100644 index 000000000..0faa9668b --- /dev/null +++ b/docs/tutorials/architecture/Intoduction_to_architectures.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python +# coding: utf-8 + +""" +=========================================== +Introduction to Architectures in Stone Soup +=========================================== +""" + +import tempfile +import numpy as np +import random +from ordered_set import OrderedSet +from datetime import datetime, timedelta +import copy + + +# %% +# Introduction +# ------------ +# +# :class:`~.stonesoup.architecture` provides functionality to build Information and Network +# architectures, to simulate sensing, propagation and fusion of data. Architectures are modelled +# by defining the nodes in the architecture, and edges that represent links between nodes. +# +# Nodes +# ----- +# +# Nodes represent points in the architecture that process the in some way. Before advancing, a +# few definitions are required: +# +# - Relationships between nodes are defined as parent-child. In a directed graph, an edge from +# node A to node B informs that data is passed from the child node, A, to the parent node, B. +# - The children of node A, TODO:Set notation here, +# is defined as the set of nodes which pass data to node A. +# - The parents of node A, TODO:Set notation here, +# is defined as the set of nodes which node A passes data to. +# +# Different types of node can provide different functionality in the architecture. The following +# are available in stonesoup: +# +# - SensorNode: makes detections of targets and propagates data onwards through the architecture. +# - FusionNode: receives data from child nodes, and fuses to achieve a fused result. Fused result +# can be propagated onwards. +# - SensorFusionNode: has the functionality of both a SensorNode and a FusionNode. +# - RepeaterNode: carries out no processing of data. Propagates forwards data that it has +# received. Cannot be used in information architecture. +# +# Set up and Node Properties +# ^^^^^^^^^^^^^^^^^^^^^^^^^^ + +from stonesoup.architecture.node import Node + +node_A = Node(label='Node A') +node_B = Node(label='Node B') +node_C = Node(label='Node C') + +# %% +# The Node base class contains several properties. The `latency` property gives functionality to +# simulate processing latency at the node. The rest of the properties (label, position, colour, +# shape, font_size, node_dim), are optional and primarily used for graph plotting. + +node_A.colour = '#006494' + +node_A.shape = 'hexagon' + +# %% +# SensorNodes and FusionNodes have additional properties that must be defined. A SensorNode must +# be given an additional `sensor` property - this must be a TODO Sensorclass. +# A FusionNode has two additional properties: `tracker` and `fusion_queue`. `tracker` must be +# a TODO Trackerclass - the tracker manages the fusion at the node. The `fusion_queue` property +# is a #TODO FusionQueueclass by default - this manages the inflow of data from child nodes. +# +# Edges +# ----- +# An edge represents a link between two nodes in an architecture. An TODO Edgeclass contains a +# property `nodes`: a tuple of TODO Nodeclass objects where the first entry in the tuple is +# the child node and the second is the parent. Edges in stonesoup are directional (data can +# flow only in one direction), with data flowing from child to parent. Edge objects also +# contain a `latency` property to enable simulation of latency caused by sending a message. + +from stonesoup.architecture.edge import Edge + +edge1 = Edge(nodes=(node_B, node_A)) +edge2 = Edge(nodes=(node_C, node_A)) + +# %% +# TODO Edgesclass is a container class for #TODO Edgeclass objects. #TODO Edgesclass has an +# `edges` property - a list of TODO Edgeclass objects. An #TODO Edgesclass object is required +# to pass into an #TODO Architectureclass + +from stonesoup.architecture.edge import Edges + +edges = Edges(edges=[edge1, edge2]) + + +# Architecture +# ------------ +# Architecture classes manage the simualtion of data propagation across a network. Two +# architecture classes are available in stonesoup: #TODO InformationArchitectureclass and +# NetworkArchitectureclass. Information architecture simulates the architecture of how +# information is shared across the network, only considering nodes that create or modify +# information. Network architecture simulates the architecture of how data is propagated through +# a network. All nodes are considered including nodes that only propagate received data onwards. +# +# Architecture classes contain an `edges` property - this must be a #TODO Edgesclass object. +# The `current_time` property of an Architecture class maintains the current time within the +# simulation. By default this begins at the current time of the operating system. + +from stonesoup.architecture import InformationArchitecture + +arch = InformationArchitecture(edges=edges) +arch.plot(tempfile.gettempdir(), save_plot=False) + + +#TODO Add simple plot image here + +# %% +# A Sensor Fusion Information Architecture example +# ------------------------------------------------ +# Using the classes detailed above, we can build an example of an information architecture and +# simulate data detection, propagation and fusion. +# +# Generate ground truth +# ^^^^^^^^^^^^^^^^^^^^^ + +from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, \ + ConstantVelocity +from stonesoup.types.groundtruth import GroundTruthPath, GroundTruthState + +start_time = datetime.now().replace(microsecond=0) +np.random.seed(1990) +random.seed(1990) + +# Generate transition model +transition_model = CombinedLinearGaussianTransitionModel([ConstantVelocity(0.005), + ConstantVelocity(0.005)]) + +yps = range(0, 100, 10) # y value for prior state +truths = OrderedSet() +ntruths = 3 # number of ground truths in simulation +time_max = 60 # timestamps the simulation is observed over +timesteps = [start_time + timedelta(seconds=k) for k in range(time_max)] + +xdirection = 1 +ydirection = 1 + +# Generate ground truths +for j in range(0, ntruths): + truth = GroundTruthPath([GroundTruthState([0, xdirection, yps[j], ydirection], + timestamp=timesteps[0])], id=f"id{j}") + + for k in range(1, time_max): + truth.append( + GroundTruthState(transition_model.function(truth[k - 1], noise=True, + time_interval=timedelta(seconds=1)), + timestamp=timesteps[k])) + truths.add(truth) + + xdirection *= -1 + if j % 2 == 0: + ydirection *= -1 + +# %% +# Create sensors +# ^^^^^^^^^^^^^^ + +total_no_sensors = 6 + +from ordered_set import OrderedSet +from stonesoup.types.state import StateVector +from stonesoup.sensor.radar.radar import RadarRotatingBearingRange +from stonesoup.types.angle import Angle + +sensor_set = OrderedSet() +for n in range(0, total_no_sensors): + sensor = RadarRotatingBearingRange( + position_mapping=(0, 2), + noise_covar=np.array([[np.radians(0.5) ** 2, 0], + [0, 1 ** 2]]), + ndim_state=4, + position=np.array([[10], [n*20 - 40]]), + rpm=60, + fov_angle=np.radians(360), + dwell_centre=StateVector([0.0]), + max_range=np.inf, + resolutions={'dwell_centre': Angle(np.radians(30))} + ) + sensor_set.add(sensor) +for sensor in sensor_set: + sensor.timestamp = start_time + +# %% +# Build Tracker +# ^^^^^^^^^^^^^ + +from stonesoup.predictor.kalman import KalmanPredictor +from stonesoup.updater.kalman import ExtendedKalmanUpdater +from stonesoup.hypothesiser.distance import DistanceHypothesiser +from stonesoup.measures import Mahalanobis +from stonesoup.dataassociator.neighbour import GNNWith2DAssignment +from stonesoup.deleter.error import CovarianceBasedDeleter +from stonesoup.types.state import GaussianState +from stonesoup.initiator.simple import MultiMeasurementInitiator +from stonesoup.tracker.simple import MultiTargetTracker +from stonesoup.architecture.edge import FusionQueue + +predictor = KalmanPredictor(transition_model) +updater = ExtendedKalmanUpdater(measurement_model=None) +hypothesiser = DistanceHypothesiser(predictor, updater, measure=Mahalanobis(), missed_distance=5) +data_associator = GNNWith2DAssignment(hypothesiser) +deleter = CovarianceBasedDeleter(covar_trace_thresh=7) +initiator = MultiMeasurementInitiator( + prior_state=GaussianState([[0], [0], [0], [0]], np.diag([0, 1, 0, 1])), + measurement_model=None, + deleter=deleter, + data_associator=data_associator, + updater=updater, + min_points=2, + ) + +tracker = MultiTargetTracker(initiator, deleter, None, data_associator, updater) + +# %% +# Build Track-Tracker +# ^^^^^^^^^^^^^^^^^^^ + +from stonesoup.updater.wrapper import DetectionAndTrackSwitchingUpdater +from stonesoup.updater.chernoff import ChernoffUpdater +from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder + +track_updater = ChernoffUpdater(None) +detection_updater = ExtendedKalmanUpdater(None) +detection_track_updater = DetectionAndTrackSwitchingUpdater(None, detection_updater, track_updater) + + +fq = FusionQueue() + +track_tracker = MultiTargetTracker( + initiator, deleter, Tracks2GaussianDetectionFeeder(fq), data_associator, + detection_track_updater) + +# ## Build the Architecture +# With ground truths, sensors and trackers built, we can design an architecture and simulate +# running it with some data. +# +# Build nodes +# ^^^^^^^^^^^ + +from stonesoup.architecture.node import SensorNode, SensorFusionNode, FusionNode +from stonesoup.architecture.edge import FusionQueue + +# Sensor Nodes +node_A = SensorNode(sensor=sensor_set[0], label='Node A') +node_B = SensorNode(sensor=sensor_set[1], label='Node B') +node_D = SensorNode(sensor=sensor_set[2], label='Node D') +node_E = SensorNode(sensor=sensor_set[3], label='Node E') +node_H = SensorNode(sensor=sensor_set[4], label='Node H') + +# Fusion Nodes +node_C_tracker = copy.deepcopy(tracker) +node_C_tracker.detector = FusionQueue() +node_C = FusionNode(tracker=node_C_tracker, fusion_queue=node_C_tracker.detector, latency=0, label='Node C') + +node_F_tracker = copy.deepcopy(tracker) +node_F_tracker.detector = FusionQueue() +node_F = FusionNode(tracker=node_F_tracker, fusion_queue=node_F_tracker.detector, latency=0, label='Node F') + +node_G = FusionNode(tracker=track_tracker, fusion_queue=FusionQueue(), latency=0, label='Node G') + +# Sensor Fusion Node +node_I_tracker = copy.deepcopy(tracker) +node_I_tracker.detector = FusionQueue() +node_I = SensorFusionNode(sensor=sensor_set[5], tracker=node_I_tracker, + fusion_queue=node_I_tracker.detector, label='Node I') + +# %% +# Build Edges +# ^^^^^^^^^^^ + +edges = Edges([Edge((node_A, node_C)), + Edge((node_B, node_C)), + Edge((node_D, node_F)), + Edge((node_E, node_F)), + Edge((node_C, node_G), edge_latency=0), + Edge((node_F, node_G), edge_latency=0), + Edge((node_H, node_I)), + Edge((node_I, node_G))]) + + +# %% +# Build Information Architecture +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +arch = InformationArchitecture(edges) +arch.plot(tempfile.gettempdir()) + diff --git a/docs/tutorials/architecture/README.rst b/docs/tutorials/architecture/README.rst new file mode 100644 index 000000000..79b94329b --- /dev/null +++ b/docs/tutorials/architecture/README.rst @@ -0,0 +1,3 @@ +Architectures +------------- +Here are some tutorials which cover use of the stonesoup.architecture package. diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index ce9812859..81bcdae7a 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -197,7 +197,8 @@ def fusion_nodes(self): return fusion def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, - bgcolour="lightgray", node_style="filled", save_plot=True, plot_style=None): + bgcolour="white", node_style="filled", font_name='helvetica', save_plot=True, + plot_style=None): """Creates a pdf plot of the directed graph and displays it :param dir_path: The path to save the pdf and .gv files to @@ -316,6 +317,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, dot = graph.to_string() dot_split = dot.split('{', maxsplit=1) dot_split.insert(1, '\n' + f"graph [bgcolor={bgcolour}]") + dot_split.insert(1, '\n' + f"node [fontname={font_name}]") dot_split.insert(1, '{ \n' + f"node [style={node_style}]") dot = ''.join(dot_split) diff --git a/stonesoup/architecture/_functions.py b/stonesoup/architecture/_functions.py index 6537f84e9..81aef41a4 100644 --- a/stonesoup/architecture/_functions.py +++ b/stonesoup/architecture/_functions.py @@ -37,7 +37,7 @@ def _default_label(node, last_letters): type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' new_letters = _default_letters(type_letters) last_letters[node_type] = new_letters - return node_type + ' ' + new_letters, last_letters + return node_type + ' ' + '\n' + new_letters, last_letters def _default_letters(type_letters) -> str: diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 645b86869..44f0bc244 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -26,16 +26,16 @@ class Node(Base): default=None, doc="Cartesian coordinates for node") colour: str = Property( - default=None, + default='#909090', doc='Colour to be displayed on graph') shape: str = Property( - default=None, + default='rectangle', doc='Shape used to display nodes') font_size: int = Property( default=5, doc='Font size for node labels') node_dim: tuple = Property( - default=None, + default=(0.5, 0.5), doc='Width and height of nodes for graph icons, default is (0.5, 0.5)') def __init__(self, *args, **kwargs): @@ -70,14 +70,14 @@ class SensorNode(Node): """A node corresponding to a Sensor. Fresh data is created here""" sensor: Sensor = Property(doc="Sensor corresponding to this node") colour: str = Property( - default='#1f77b4', - doc='Colour to be displayed on graph. Default is the hex colour code #1f77b4') + default='#006eff', + doc='Colour to be displayed on graph. Default is the hex colour code #006eff') shape: str = Property( default='oval', doc='Shape used to display nodes. Default is an oval') node_dim: tuple = Property( - default=(0.5, 0.3), - doc='Width and height of nodes for graph icons. Default is (0.5, 0.3)') + default=(0.6, 0.3), + doc='Width and height of nodes for graph icons. Default is (0.6, 0.3)') class FusionNode(Node): @@ -91,14 +91,14 @@ class FusionNode(Node): tracks: set = Property(default=None, doc="Set of tracks tracked by the fusion node") colour: str = Property( - default='#006400', - doc='Colour to be displayed on graph. Default is the hex colour code #006400') + default='#00b53d', + doc='Colour to be displayed on graph. Default is the hex colour code #00b53d') shape: str = Property( default='hexagon', doc='Shape used to display nodes. Default is a hexagon') node_dim: tuple = Property( - default=(0.6, 0.3), - doc='Width and height of nodes for graph icons. Default is (0.6, 0.3)') + default=(0.8, 0.4), + doc='Width and height of nodes for graph icons. Default is (0.8, 0.4)') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -147,24 +147,24 @@ def _track_thread(tracker, input_queue, output_queue): class SensorFusionNode(SensorNode, FusionNode): """A node that is both a sensor and also processes data""" colour: str = Property( - default='#909090', - doc='Colour to be displayed on graph. Default is the hex colour code #909090') + default='#fc9000', + doc='Colour to be displayed on graph. Default is the hex colour code #fc9000') shape: str = Property( - default='rectangle', - doc='Shape used to display nodes. Default is a rectangle') + default='diamond', + doc='Shape used to display nodes. Default is a diamond') node_dim: tuple = Property( - default=(0.1, 0.3), - doc='Width and height of nodes for graph icons. Default is (0.1, 0.3)') + default=(0.9, 0.5), + doc='Width and height of nodes for graph icons. Default is (0.9, 0.5)') class RepeaterNode(Node): """A node which simply passes data along to others, without manipulating the data itself. """ colour: str = Property( - default='#ff7f0e', - doc='Colour to be displayed on graph. Default is the hex colour code #ff7f0e') + default='#909090', + doc='Colour to be displayed on graph. Default is the hex colour code #909090') shape: str = Property( - default='circle', - doc='Shape used to display nodes. Default is a circle') + default='rectangle', + doc='Shape used to display nodes. Default is a rectangle') node_dim: tuple = Property( - default=(0.5, 0.3), - doc='Width and height of nodes for graph icons. Default is (0.5, 0.3)') + default=(0.7, 0.4), + doc='Width and height of nodes for graph icons. Default is (0.7, 0.4)') diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 3d8181968..af152396b 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -6,7 +6,7 @@ import copy from ..edge import Edge, DataPiece, Edges -from ..node import Node, RepeaterNode, SensorNode, FusionNode +from ..node import Node, RepeaterNode, SensorNode, FusionNode, SensorFusionNode from ...types.track import Track from ...sensor.categorical import HMMSensor from ...models.measurement.categorical import MarkovianMeasurementModel @@ -142,7 +142,7 @@ def ground_truths(transition_model, times): @pytest.fixture def radar_sensors(times): start_time = times["start"] - total_no_sensors = 5 + total_no_sensors = 6 sensor_set = OrderedSet() for n in range(0, total_no_sensors): sensor = RadarRotatingBearingRange( @@ -268,8 +268,14 @@ def radar_nodes(radar_sensors, fusion_queue): node_G = FusionNode(tracker=track_tracker, fusion_queue=fusion_queue, latency=0) + node_I_tracker = copy.deepcopy(tracker) + node_I_tracker.detector = FusionQueue() + + node_I = SensorFusionNode(sensor=sensor_set[5], tracker=node_I_tracker, + fusion_queue=node_I_tracker.detector) + return {'a': node_A, 'b': node_B, 'c': node_C, 'd': node_D, 'e': node_E, 'f': node_F, - 'g': node_G, 'h': node_H} + 'g': node_G, 'h': node_H, 'i':node_I} @pytest.fixture @@ -323,9 +329,18 @@ def edge_lists(nodes, radar_nodes): Edge((radar_nodes['f'], radar_nodes['g']), edge_latency=0), Edge((radar_nodes['h'], radar_nodes['g']))]) + sf_radar_edges = Edges([Edge((radar_nodes['a'], radar_nodes['c'])), + Edge((radar_nodes['b'], radar_nodes['c'])), + Edge((radar_nodes['d'], radar_nodes['f'])), + Edge((radar_nodes['e'], radar_nodes['f'])), + Edge((radar_nodes['c'], radar_nodes['g']), edge_latency=0), + Edge((radar_nodes['f'], radar_nodes['g']), edge_latency=0), + Edge((radar_nodes['h'], radar_nodes['i'])), + Edge((radar_nodes['i'], radar_nodes['g']))]) + return {"hierarchical_edges": hierarchical_edges, "centralised_edges": centralised_edges, "simple_edges": simple_edges, "linear_edges": linear_edges, "decentralised_edges": decentralised_edges, "disconnected_edges": disconnected_edges, "k4_edges": k4_edges, "circular_edges": circular_edges, "disconnected_loop_edges": disconnected_loop_edges, "repeater_edges": repeater_edges, - "radar_edges": radar_edges} + "radar_edges": radar_edges, "sf_radar_edges": sf_radar_edges} diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 6293ece8d..7ef1eef2e 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -10,6 +10,7 @@ def test_hierarchical_plot(tmpdir, nodes, edge_lists): edges = edge_lists["hierarchical_edges"] + sf_radar_edges = edge_lists["sf_radar_edges"] arch = InformationArchitecture(edges=edges) @@ -41,6 +42,10 @@ def test_hierarchical_plot(tmpdir, nodes, edge_lists): with pytest.raises(ValueError): arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_style='hierarchical') + arch2 = InformationArchitecture(edges=sf_radar_edges) + + arch2.plot(dir_path=tmpdir.join('test2.pdf')) + def test_plot_title(nodes, tmpdir, edge_lists): edges = edge_lists["decentralised_edges"] From 958a2a37e4d89a8345e1d5f056eddb33094e8087 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 5 Sep 2023 13:18:07 +0100 Subject: [PATCH 084/170] Add pydot to requirements. Updates to architecture plot() and relevant tests. Image files added for architecture tutorial and updates to thhe doc. --- .../architecture_sensorfusionexample.png | Bin 0 -> 48619 bytes .../_static/architecture_simpleexample.png | Bin 0 -> 17330 bytes .../Intoduction_to_architectures.py | 108 ++++++++++-------- docs/tutorials/architecture/README.rst | 4 +- setup.cfg | 1 + stonesoup/architecture/tests/conftest.py | 2 +- .../architecture/tests/test_architecture.py | 6 +- .../architecture/tests/test_functions.py | 4 +- stonesoup/architecture/tests/test_node.py | 20 ++-- 9 files changed, 82 insertions(+), 63 deletions(-) create mode 100644 docs/source/_static/architecture_sensorfusionexample.png create mode 100644 docs/source/_static/architecture_simpleexample.png diff --git a/docs/source/_static/architecture_sensorfusionexample.png b/docs/source/_static/architecture_sensorfusionexample.png new file mode 100644 index 0000000000000000000000000000000000000000..6397416c0714446e7f1f049236bbbff06e652fa7 GIT binary patch literal 48619 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

kO%U#3jeIcH^_Zv}}yD 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Ⓢdsj+-kUN1H8 zJi}@x)+dsFcPjP|%I;cNYw75`M(64b8xghsGNwk2!+W-arK_iLrCye)tNxGC zk4bxMgj@WHY8x^?sEJVx4a(<$�TPa_+^BMndZ)B<*no5_+ND8<3bT?f|FR=K(pU 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 literal 0 HcmV?d00001 diff --git a/docs/source/_static/architecture_simpleexample.png b/docs/source/_static/architecture_simpleexample.png new file mode 100644 index 0000000000000000000000000000000000000000..681573639c8a5baf7248970e709ef9a4f6b63f45 GIT binary patch literal 17330 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 Date: Mon, 16 Oct 2023 09:13:24 +0100 Subject: [PATCH 085/170] Progress on NetworkArchitecture --- stonesoup/architecture/__init__.py | 195 +++++++++++++++++++++++++++-- stonesoup/architecture/edge.py | 9 +- 2 files changed, 192 insertions(+), 12 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 81bcdae7a..680afd787 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -1,10 +1,11 @@ +import copy from abc import abstractmethod import pydot from ..base import Base, Property from .node import Node, SensorNode, RepeaterNode, FusionNode -from .edge import Edges, DataPiece +from .edge import Edges, DataPiece, Edge from ..types.groundtruth import GroundTruthPath from ..types.detection import TrueDetection, Clutter from ._functions import _default_label @@ -196,6 +197,17 @@ def fusion_nodes(self): fusion.add(node) return fusion + @property + def repeater_nodes(self): + """ + Returns a set of all RepeaterNodes in the :class:`Architecture`. + """ + repeater_nodes = set() + for node in self.all_nodes: + if isinstance(node, RepeaterNode): + repeater_nodes.add(node) + return repeater_nodes + def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, bgcolour="white", node_style="filled", font_name='helvetica', save_plot=True, plot_style=None): @@ -396,6 +408,15 @@ def fully_propagated(self): return True +class NonPropagatingArchitecture(Architecture): + """ + A simple Architecture class that does not simulate propagation of any data. Can be used for + performing network operations on an :class:`~.Edges` object. + """ + def propagate(self, time_increment: float): + pass + + class InformationArchitecture(Architecture): """The architecture for how information is shared through the network. Node A is " "connected to Node B if and only if the information A creates by processing and/or " @@ -403,9 +424,11 @@ class InformationArchitecture(Architecture): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - for node in self.all_nodes: - if isinstance(node, RepeaterNode): - raise TypeError("Information architecture should not contain any repeater nodes") + + if isinstance(self, InformationArchitecture): + for node in self.all_nodes: + if isinstance(node, RepeaterNode): + raise TypeError("Information architecture should not contain any repeater nodes") for fusion_node in self.fusion_nodes: pass # fusion_node.tracker.set_time(self.current_time) @@ -474,14 +497,112 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): class NetworkArchitecture(Architecture): """The architecture for how data is propagated through the network. Node A is connected " "to Node B if and only if A sends its data through B. """ + information_arch: InformationArchitecture = Property(default=None) + information_architecture_edges: Edges = Property(default=None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Check whether an InformationArchitecture is provided, if not, see if one can be created + if self.information_arch is None: + + # If info edges are provided, we can deduce an information architecture, otherwise: + if self.information_architecture_edges is None: + + # If repeater nodes are present in the Network architecture, we can deduce an + # information architecture + if len(self.repeater_nodes) > 0: + self.information_architecture_edges = Edges(inherit_edges(Edges(self.edges))) + self.information_arch = InformationArchitecture( + edges=self.information_architecture_edges) + else: + self.information_arch = InformationArchitecture( + edges=self.information_architecture_edges) + + # Need to reset digraph for info-arch + self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) + # Set attributes such as label, colour, shape, etc. for each node + last_letters = {'Node': '', 'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', + 'RepeaterNode': ''} + for node in self.di_graph.nodes: + if node.label: + label = node.label + else: + label, last_letters = _default_label(node, last_letters) + node.label = label + attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", + "fontsize": f"{node.font_size}", "width": f"{node.node_dim[0]}", + "height": f"{node.node_dim[1]}", "fixedsize": True} + self.di_graph.nodes[node].update(attr) + + # def propagate(self, time_increment: float): + # # Still have to deal with latency/bandwidth + # self.current_time += timedelta(seconds=time_increment) + # for node in self.all_nodes: + # for recipient in self.recipients(node): + # for data in node.data_held: + # recipient.update(self.current_time, data) + + def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, + **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: + """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ + all_detections = dict() + + # Get rid of ground truths that have not yet happened + # (ie GroundTruthState's with timestamp after self.current_time) + new_ground_truths = set() + for ground_truth_path in ground_truths: + # need an if len(states) == 0 continue condition here? + new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) + + for sensor_node in self.sensor_nodes: + all_detections[sensor_node] = set() + for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): + all_detections[sensor_node].add(detection) + + # Borrowed below from SensorSuite. I don't think it's necessary, but might be something + # we need. If so, will need to define self.attributes_inform + + # attributes_dict = \ + # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) + # for attribute_name in self.attributes_inform} + # + # for detection in all_detections[sensor_node]: + # detection.metadata.update(attributes_dict) + + for data in all_detections[sensor_node]: + # The sensor acquires its own data instantly + sensor_node.update(data.timestamp, data.timestamp, + DataPiece(sensor_node, sensor_node, data, data.timestamp), + 'created') + + return all_detections + + def propagate(self, time_increment: float, failed_edges: Collection = None): + """Performs the propagation of the measurements through the network""" + for edge in self.edges.edges: + if failed_edges and edge in failed_edges: + edge._failed(self.current_time, time_increment) + continue # No data passed along these edges + edge.update_messages(self.current_time) + # fuse goes here? + for data_piece, time_pertaining in edge.unsent_data: + edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) + + # for node in self.processing_nodes: + # node.process() # This should happen when a new message is received + count = 0 + if not self.fully_propagated: + count += 1 + self.propagate(time_increment, failed_edges) + return + + for fuse_node in self.fusion_nodes: + fuse_node.fuse() - def propagate(self, time_increment: float): - # Still have to deal with latency/bandwidth self.current_time += timedelta(seconds=time_increment) - for node in self.all_nodes: - for recipient in self.recipients(node): - for data in node.data_held: - recipient.update(self.current_time, data) + for fusion_node in self.fusion_nodes: + pass # fusion_node.tracker.set_time(self.current_time) class CombinedArchitecture(Base): @@ -500,3 +621,57 @@ def propagate(self, time_increment: float): # Some magic here failed_edges = [] # return this from n_arch.propagate? self.information_architecture.propagate(time_increment, failed_edges) + + +def inherit_edges(network_architecture): + + edges = list() + for edge in network_architecture.edges: + edges.append(edge) + + temp_arch = NonPropagatingArchitecture(edges=Edges(edges)) + # Iterate through repeater nodes in the Network Architecture to find edges to remove + for repeaternode in temp_arch.repeater_nodes: + to_replace = list() + to_add = list() + + senders = temp_arch.senders(repeaternode) + recipients = temp_arch.recipients(repeaternode) + + # Find all edges that pass data to the repeater node + for sender in senders: + edges = temp_arch.edges.get((sender, repeaternode)) + to_replace += edges + + # Find all edges that pass data from the repeater node + for recipient in recipients: + edges = temp_arch.edges.get((repeaternode, recipient)) + to_replace += edges + + # Create a new edge from every sender to every recipient + for sender in senders: + for recipient in recipients: + + # Could be possible edges from sender to node, choose path of minimum latency + poss_edges_to = temp_arch.edges.get((sender, repeaternode)) + latency_to = np.inf + for edge in poss_edges_to: + latency_to = edge.edge_latency if edge.edge_latency <= latency_to else \ + latency_to + + # Could be possible edges from node to recipient, choose path of minimum latency + poss_edges_from = temp_arch.edges.get((sender, repeaternode)) + latency_from = np.inf + for edge in poss_edges_from: + latency_from = edge.edge_latency if edge.edge_latency <= latency_from else \ + latency_from + + latency = latency_to + latency_from + repeaternode.latency + edge = Edge(nodes=(sender, recipient), edge_latency=latency) + to_add.append(edge) + + for edge in to_replace: + temp_arch.edges.remove(edge) + for edge in to_add: + temp_arch.edges.add(edge) + return temp_arch.edges diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index a397cccd6..e92cd3ed8 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -170,16 +170,21 @@ def __contains__(self, item): def add(self, edge): self.edges.append(edge) + def remove(self, edge): + self.edges.remove(edge) + def get(self, node_pair): if not isinstance(node_pair, Tuple) and all(isinstance(node, Node) for node in node_pair): raise TypeError("Must supply a tuple of nodes") if not len(node_pair) == 2: raise ValueError("Incorrect tuple length. Must be of length 2") + edges = list() for edge in self.edges: if edge.nodes == node_pair: # Assume this is the only match? - return edge - return None + edges.append(edge) + return edges + @property def edge_list(self): From f208d5a7d36915693cee1c7110e8c16390161665 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 16 Oct 2023 16:26:41 +0100 Subject: [PATCH 086/170] NetworkArchitecture propagate() added functionality for nodes that don't process Messages. Necessary changes to Node and Edge functions for compatability. --- stonesoup/architecture/__init__.py | 55 ++++++++++++++++-------------- stonesoup/architecture/edge.py | 47 ++++++++++++++++++++++--- stonesoup/architecture/node.py | 1 + 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 680afd787..7a2f74487 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -514,10 +514,10 @@ def __init__(self, *args, **kwargs): if len(self.repeater_nodes) > 0: self.information_architecture_edges = Edges(inherit_edges(Edges(self.edges))) self.information_arch = InformationArchitecture( - edges=self.information_architecture_edges) + edges=self.information_architecture_edges, current_time=self.current_time) else: self.information_arch = InformationArchitecture( - edges=self.information_architecture_edges) + edges=self.information_architecture_edges, current_time=self.current_time) # Need to reset digraph for info-arch self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) @@ -580,15 +580,27 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd def propagate(self, time_increment: float, failed_edges: Collection = None): """Performs the propagation of the measurements through the network""" + + # Update each edge with messages received/sent for edge in self.edges.edges: if failed_edges and edge in failed_edges: edge._failed(self.current_time, time_increment) continue # No data passed along these edges - edge.update_messages(self.current_time) - # fuse goes here? + + if edge.recipient not in self.information_arch.all_nodes: + edge.update_messages(self.current_time, to_network_node=True) + else: + edge.update_messages(self.current_time) + + # Send available messages from nodes to the edges for data_piece, time_pertaining in edge.unsent_data: edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) + for message in edge.sender.messages_to_pass_on['unsent']: + edge.pass_message(message) + edge.sender.messages_to_pass_on['sent'].append(message) + edge.sender.messages_to_pass_on['unsent'].remove(message) + # for node in self.processing_nodes: # node.process() # This should happen when a new message is received count = 0 @@ -605,31 +617,22 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): pass # fusion_node.tracker.set_time(self.current_time) -class CombinedArchitecture(Base): - """Contains an information and a network architecture that pertain to the same scenario. """ - information_architecture: InformationArchitecture = Property( - doc="The information architecture for how information is shared. ") - network_architecture: NetworkArchitecture = Property( - doc="The architecture for how data is propagated through the network. Node A is connected " - "to Node B if and only if A sends its data through B. ") - - def propagate(self, time_increment: float): - # First we simulate the network - self.network_architecture.propagate(time_increment) - # Now we want to only pass information along in the information architecture if it - # Was in the information architecture by at least one path. - # Some magic here - failed_edges = [] # return this from n_arch.propagate? - self.information_architecture.propagate(time_increment, failed_edges) - - def inherit_edges(network_architecture): + """ + Utility function that takes a NetworkArchitecture object and infers what the overlaying + InformationArchitecture graph would be. + + :param network_architecture: A NetworkArchitecture object + :return: A list of edges. + """ - edges = list() - for edge in network_architecture.edges: - edges.append(edge) + # edges = list() + # for edge in network_architecture.edges: + # edges.append(edge) + # temp_arch = NonPropagatingArchitecture(edges=Edges(edges)) + edges = copy.copy(network_architecture.edges) + temp_arch = NonPropagatingArchitecture(edges) - temp_arch = NonPropagatingArchitecture(edges=Edges(edges)) # Iterate through repeater nodes in the Network Architecture to find edges to remove for repeaternode in temp_arch.repeater_nodes: to_replace = list() diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index e92cd3ed8..7248b3078 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -83,6 +83,16 @@ def __init__(self, *args, **kwargs): self.time_range_failed = CompoundTimeRange() # Times during which this edge was failed def send_message(self, data_piece, time_pertaining, time_sent): + """ + Takes a piece of data retrieved from the edge's sender node, and propagates it + along the edge + :param data_piece: DataPiece object pulled from the edge's sender. + :param time_pertaining: The latest time for which the data pertains. For a Detection, this + would be the time of the Detection, or for a Track this is the time of the last State in + the Track + :param time_sent: Time at which the message was sent + :return: None + """ if not isinstance(data_piece, DataPiece): raise TypeError("Message info must be one of the following types: " "Detection, Hypothesis or Track") @@ -92,7 +102,27 @@ def send_message(self, data_piece, time_pertaining, time_sent): # ensure message not re-sent data_piece.sent_to.add(self.nodes[1]) - def update_messages(self, current_time): + def pass_message(self, message): + """ + Takes a message from a Node's 'messages_to_pass_on' store and propagates them to the + relevant edges. + :param message: Message to propagate + :return: None + """ + _, self.messages_held = _dict_set(self.messages_held, message, 'pending', message.time_sent) + # Message not opened by repeater node, remove node from 'sent_to' + # message.data_piece.sent_to.remove(self.nodes[0]) + message.data_piece.sent_to.add(self.nodes[1]) + + def update_messages(self, current_time, to_network_node=False): + """ + Updates the category of messages stored in edge.messages_held if latency time has passed. + Adds messages that have 'arrived' at recipient to the relevant holding area of the node. + :param current_time: Current time in simulation + :param to_network_node: Bool that is true if recipient node is not in the information + architecture + :return: None + """ # Check info type is what we expect to_remove = set() # Needed as we can't change size of a set during iteration for time in self.messages_held['pending']: @@ -104,9 +134,15 @@ def update_messages(self, current_time): to_remove.add((time, message)) _, self.messages_held = _dict_set(self.messages_held, message, 'received', message.arrival_time) - # Update - message.recipient_node.update(message.time_pertaining, message.arrival_time, - message.data_piece, "unfused") + + # Update node according to inclusion in Information Architecture + if to_network_node: + message.recipient_node.messages_to_pass_on['unsent'].append(message) + else: + # Update + message.recipient_node.update(message.time_pertaining, + message.arrival_time, + message.data_piece, "unfused") for time, message in to_remove: self.messages_held['pending'][time].remove(message) @@ -185,7 +221,6 @@ def get(self, node_pair): edges.append(edge) return edges - @property def edge_list(self): """Returns a list of tuples in the form (sender, recipient)""" @@ -211,6 +246,8 @@ class Message(Base): doc="Time at which the message was sent") data_piece: DataPiece = Property( doc="Info that the sent message contains") + # destination: Node = Property(doc="Node in the information architecture that the message is " + # "being sent to") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 44f0bc244..9546bb418 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -41,6 +41,7 @@ class Node(Base): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.data_held = {"fused": {}, "created": {}, "unfused": {}} + self.messages_to_pass_on = {'unsent': [], 'sent': []} def update(self, time_pertaining, time_arrived, data_piece, category, track=None): if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): From d8c7ee411392481c17b8447bd65f6a5fa21807ea Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 19 Oct 2023 12:01:14 +0100 Subject: [PATCH 087/170] Additional functionality to NetworkArchitecture to enable passing of information through nodes without processing, and functionaity to give a Message a destination --- stonesoup/architecture/__init__.py | 40 ++++++++++++++++-------- stonesoup/architecture/edge.py | 50 +++++++++++++++++++++--------- stonesoup/architecture/node.py | 7 ++++- 3 files changed, 68 insertions(+), 29 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 7a2f74487..fef633afc 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -593,13 +593,13 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): edge.update_messages(self.current_time) # Send available messages from nodes to the edges - for data_piece, time_pertaining in edge.unsent_data: - edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) - - for message in edge.sender.messages_to_pass_on['unsent']: - edge.pass_message(message) - edge.sender.messages_to_pass_on['sent'].append(message) - edge.sender.messages_to_pass_on['unsent'].remove(message) + if edge.sender in self.information_arch.all_nodes: + for data_piece, time_pertaining in edge.unsent_data: + edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) + else: + for message in edge.sender.messages_to_pass_on: + if edge.recipient not in message.data_piece.sent_to: + edge.pass_message(message) # for node in self.processing_nodes: # node.process() # This should happen when a new message is received @@ -616,6 +616,22 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): for fusion_node in self.fusion_nodes: pass # fusion_node.tracker.set_time(self.current_time) + @property + def fully_propagated(self): + """Checks if all data for each node have been transferred + to its recipients. With zero latency, this should be the case after running propagate""" + for edge in self.edges.edges: + if edge.sender in self.information_arch.all_nodes: + if len(edge.unsent_data) != 0: + return False + if len(edge.unpassed_data) != 0: + return False + else: + if len(edge.unpassed_data) != 0: + return False + + return True + def inherit_edges(network_architecture): """ @@ -626,12 +642,10 @@ def inherit_edges(network_architecture): :return: A list of edges. """ - # edges = list() - # for edge in network_architecture.edges: - # edges.append(edge) - # temp_arch = NonPropagatingArchitecture(edges=Edges(edges)) - edges = copy.copy(network_architecture.edges) - temp_arch = NonPropagatingArchitecture(edges) + edges = list() + for edge in network_architecture.edges: + edges.append(edge) + temp_arch = NonPropagatingArchitecture(edges=Edges(edges)) # Iterate through repeater nodes in the Network Architecture to find edges to remove for repeaternode in temp_arch.repeater_nodes: diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 7248b3078..79faa5669 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -12,7 +12,7 @@ from ._functions import _dict_set if TYPE_CHECKING: - from .node import Node + from .node import Node, RepeaterNode class FusionQueue(Queue): @@ -109,6 +109,7 @@ def pass_message(self, message): :param message: Message to propagate :return: None """ + message.edge = self _, self.messages_held = _dict_set(self.messages_held, message, 'pending', message.time_sent) # Message not opened by repeater node, remove node from 'sent_to' # message.data_piece.sent_to.remove(self.nodes[0]) @@ -136,13 +137,20 @@ def update_messages(self, current_time, to_network_node=False): 'received', message.arrival_time) # Update node according to inclusion in Information Architecture - if to_network_node: - message.recipient_node.messages_to_pass_on['unsent'].append(message) + # Add message to recipient.messages_to_pass_on if the recipient is not in the + #information + if to_network_node or ((message.destinations) and + (self.recipient not in message.destinations)): + # message.recipient_node.messages_to_pass_on['unsent'].append(message) + self.recipient.messages_to_pass_on.append(message) else: # Update - message.recipient_node.update(message.time_pertaining, - message.arrival_time, - message.data_piece, "unfused") + # message.recipient_node.update(message.time_pertaining, + # message.arrival_time, + # message.data_piece, "unfused") + self.recipient.update(message.time_pertaining, + message.arrival_time, + message.data_piece, "unfused") for time, message in to_remove: self.messages_held['pending'][time].remove(message) @@ -168,16 +176,28 @@ def ovr_latency(self): """Overall latency including the two Nodes and the edge latency.""" return self.sender.latency + self.edge_latency + @property + def unpassed_data(self): + unpassed = [] + for message in self.sender.messages_to_pass_on: + if self.recipient not in message.data_piece.sent_to: + unpassed.append(message) + return unpassed + @property def unsent_data(self): """Data held by the sender that has not been sent to the recipient.""" unsent = [] - for status in ["fused", "created"]: - for time_pertaining in self.sender.data_held[status]: - for data_piece in self.sender.data_held[status][time_pertaining]: - if self.recipient not in data_piece.sent_to: - unsent.append((data_piece, time_pertaining)) - return unsent + if isinstance(type(self.sender.data_held), type(None)) or self.sender.data_held is None: + return unsent + else: + for status in ["fused", "created"]: + for time_pertaining in self.sender.data_held[status]: + for data_piece in self.sender.data_held[status][time_pertaining]: + # Data will be sent to any nodes it hasn't been sent to before + if self.recipient not in data_piece.sent_to: + unsent.append((data_piece, time_pertaining)) + return unsent def __eq__(self, other): if not isinstance(other, type(self)): @@ -217,7 +237,6 @@ def get(self, node_pair): edges = list() for edge in self.edges: if edge.nodes == node_pair: - # Assume this is the only match? edges.append(edge) return edges @@ -246,8 +265,9 @@ class Message(Base): doc="Time at which the message was sent") data_piece: DataPiece = Property( doc="Info that the sent message contains") - # destination: Node = Property(doc="Node in the information architecture that the message is " - # "being sent to") + destinations: set["Node"] = Property(doc="Nodes in the information architecture that the message is " + "being sent to", + default=None) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 9546bb418..8ecdde7a7 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -41,7 +41,7 @@ class Node(Base): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.data_held = {"fused": {}, "created": {}, "unfused": {}} - self.messages_to_pass_on = {'unsent': [], 'sent': []} + self.messages_to_pass_on = [] def update(self, time_pertaining, time_arrived, data_piece, category, track=None): if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): @@ -169,3 +169,8 @@ class RepeaterNode(Node): node_dim: tuple = Property( default=(0.7, 0.4), doc='Width and height of nodes for graph icons. Default is (0.7, 0.4)') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.data_held = None + From 1439679f6b975e5117e24fd88a0ccebebc825315 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 24 Oct 2023 15:28:24 +0100 Subject: [PATCH 088/170] Fixes to NetworkArchitecture() to consider edge cases, added tests for Network Architecture --- stonesoup/architecture/__init__.py | 6 +- stonesoup/architecture/edge.py | 59 ++++-- stonesoup/architecture/node.py | 1 - stonesoup/architecture/tests/conftest.py | 26 ++- .../architecture/tests/test_architecture.py | 173 ++++++++++++++++- stonesoup/architecture/tests/test_edge.py | 178 ++++++++++++++++++ 6 files changed, 407 insertions(+), 36 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index fef633afc..b8fa5f966 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -1,4 +1,3 @@ -import copy from abc import abstractmethod import pydot @@ -428,7 +427,8 @@ def __init__(self, *args, **kwargs): if isinstance(self, InformationArchitecture): for node in self.all_nodes: if isinstance(node, RepeaterNode): - raise TypeError("Information architecture should not contain any repeater nodes") + raise TypeError("Information architecture should not contain any repeater " + "nodes") for fusion_node in self.fusion_nodes: pass # fusion_node.tracker.set_time(self.current_time) @@ -515,6 +515,8 @@ def __init__(self, *args, **kwargs): self.information_architecture_edges = Edges(inherit_edges(Edges(self.edges))) self.information_arch = InformationArchitecture( edges=self.information_architecture_edges, current_time=self.current_time) + else: + self.information_arch = InformationArchitecture(self.edges, self.current_time) else: self.information_arch = InformationArchitecture( edges=self.information_architecture_edges, current_time=self.current_time) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 79faa5669..147dba95f 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -1,3 +1,4 @@ +import copy from collections.abc import Collection from typing import Union, Tuple, List, TYPE_CHECKING from numbers import Number @@ -12,7 +13,7 @@ from ._functions import _dict_set if TYPE_CHECKING: - from .node import Node, RepeaterNode + from .node import Node class FusionQueue(Queue): @@ -97,7 +98,8 @@ def send_message(self, data_piece, time_pertaining, time_sent): raise TypeError("Message info must be one of the following types: " "Detection, Hypothesis or Track") # Add message to 'pending' dict of edge - message = Message(self, time_pertaining, time_sent, data_piece) + message = Message(edge=self, time_pertaining=time_pertaining, time_sent=time_sent, + data_piece=data_piece, destinations={self.recipient}) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) # ensure message not re-sent data_piece.sent_to.add(self.nodes[1]) @@ -109,11 +111,15 @@ def pass_message(self, message): :param message: Message to propagate :return: None """ - message.edge = self - _, self.messages_held = _dict_set(self.messages_held, message, 'pending', message.time_sent) + message_copy = copy.copy(message) + message_copy.edge = self + if message_copy.destinations == {self.sender} or message.destinations is None: + message_copy.destinations = {self.recipient} + _, self.messages_held = _dict_set(self.messages_held, message_copy, 'pending', + message_copy.time_sent) # Message not opened by repeater node, remove node from 'sent_to' # message.data_piece.sent_to.remove(self.nodes[0]) - message.data_piece.sent_to.add(self.nodes[1]) + message_copy.data_piece.sent_to.add(self.nodes[1]) def update_messages(self, current_time, to_network_node=False): """ @@ -136,21 +142,29 @@ def update_messages(self, current_time, to_network_node=False): _, self.messages_held = _dict_set(self.messages_held, message, 'received', message.arrival_time) + # Assign destination as recipient of edge if no destination provided + if message.destinations is None: + message.destinations = {self.recipient} + # Update node according to inclusion in Information Architecture - # Add message to recipient.messages_to_pass_on if the recipient is not in the - #information - if to_network_node or ((message.destinations) and - (self.recipient not in message.destinations)): - # message.recipient_node.messages_to_pass_on['unsent'].append(message) - self.recipient.messages_to_pass_on.append(message) - else: - # Update - # message.recipient_node.update(message.time_pertaining, - # message.arrival_time, - # message.data_piece, "unfused") + if not to_network_node and message.destinations == {self.recipient}: + # Add data to recipient's data_held + self.recipient.update(message.time_pertaining, + message.arrival_time, + message.data_piece, "unfused") + + elif not to_network_node and self.recipient in message.destinations: + # Add data to recipient's data held, and message to messages_to_pass_on self.recipient.update(message.time_pertaining, message.arrival_time, message.data_piece, "unfused") + message.destinations = None + self.recipient.messages_to_pass_on.append(message) + + elif to_network_node or self.recipient not in message.destinations: + # Add message to recipient's messages_to_pass_on + message.destinations = None + self.recipient.messages_to_pass_on.append(message) for time, message in to_remove: self.messages_held['pending'][time].remove(message) @@ -265,8 +279,8 @@ class Message(Base): doc="Time at which the message was sent") data_piece: DataPiece = Property( doc="Info that the sent message contains") - destinations: set["Node"] = Property(doc="Nodes in the information architecture that the message is " - "being sent to", + destinations: set["Node"] = Property(doc="Nodes in the information architecture that the " + "message is being sent to", default=None) def __init__(self, *args, **kwargs): @@ -301,7 +315,12 @@ def update(self, current_time): def __eq__(self, other): if not isinstance(other, type(self)): return False - return all(getattr(self, name) == getattr(other, name) for name in type(self).properties) + + return all(getattr(self, name) == getattr(other, name) + for name in type(self).properties + if name not in ['destinations', 'edge']) def __hash__(self): - return hash(tuple(getattr(self, name) for name in type(self).properties)) + return hash(tuple(getattr(self, name) + for name in type(self).properties + if name not in ['destinations', 'edge'])) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 8ecdde7a7..8631af015 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -173,4 +173,3 @@ class RepeaterNode(Node): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.data_held = None - diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index e5b007a50..41c1c30ca 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -62,14 +62,19 @@ def nodes(): sensornode_6 = SensorNode(sensor=hmm_sensor, label='s6') sensornode_7 = SensorNode(sensor=hmm_sensor, label='s7') sensornode_8 = SensorNode(sensor=hmm_sensor, label='s8') - repeaternode_1 = RepeaterNode() + repeaternode_1 = RepeaterNode(label='r1') + repeaternode_2 = RepeaterNode(label='r2') + repeaternode_3 = RepeaterNode(label='r3') + repeaternode_4 = RepeaterNode(label='r4') + pnode_1 = SensorNode(sensor=hmm_sensor, label='p1', position=(0, 0)) pnode_2 = SensorNode(sensor=hmm_sensor, label='p2', position=(-1, -1)) pnode_3 = SensorNode(sensor=hmm_sensor, label='p3', position=(1, -1)) return {"a": node_a, "b": node_b, "s1": sensornode_1, "s2": sensornode_2, "s3": sensornode_3, "s4": sensornode_4, "s5": sensornode_5, "s6": sensornode_6, "s7": sensornode_7, - "s8": sensornode_8, "r1": repeaternode_1, "p1": pnode_1, "p2": pnode_2, "p3": pnode_3} + "s8": sensornode_8, "r1": repeaternode_1, "r2": repeaternode_2, "r3": repeaternode_3, + "r4": repeaternode_4, "p1": pnode_1, "p2": pnode_2, "p3": pnode_3} @pytest.fixture @@ -338,9 +343,24 @@ def edge_lists(nodes, radar_nodes): Edge((radar_nodes['h'], radar_nodes['i'])), Edge((radar_nodes['i'], radar_nodes['g']))]) + network_edges = Edges([ + Edge((radar_nodes['a'], nodes['r1']), edge_latency=0.5), + Edge((nodes['r1'], radar_nodes['c']), edge_latency=0.5), + Edge((radar_nodes['b'], radar_nodes['c'])), + Edge((radar_nodes['a'], nodes['r2']), edge_latency=0.5), + Edge((nodes['r2'], radar_nodes['c'])), + Edge((nodes['r1'], nodes['r2'])), + Edge((radar_nodes['d'], radar_nodes['f'])), + Edge((radar_nodes['e'], radar_nodes['f'])), + Edge((radar_nodes['c'], radar_nodes['g']), edge_latency=0), + Edge((radar_nodes['f'], radar_nodes['g']), edge_latency=0), + Edge((radar_nodes['h'], radar_nodes['g'])) + ]) + return {"hierarchical_edges": hierarchical_edges, "centralised_edges": centralised_edges, "simple_edges": simple_edges, "linear_edges": linear_edges, "decentralised_edges": decentralised_edges, "disconnected_edges": disconnected_edges, "k4_edges": k4_edges, "circular_edges": circular_edges, "disconnected_loop_edges": disconnected_loop_edges, "repeater_edges": repeater_edges, - "radar_edges": radar_edges, "sf_radar_edges": sf_radar_edges} + "radar_edges": radar_edges, "sf_radar_edges": sf_radar_edges, + "network_edges": network_edges} diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 8c1607d9e..b65fe582e 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -1,9 +1,12 @@ +import copy + import pytest import datetime -from stonesoup.architecture import InformationArchitecture -from ..edge import Edge, Edges -from ..node import RepeaterNode +from stonesoup.architecture import InformationArchitecture, NetworkArchitecture, \ + NonPropagatingArchitecture +from ..edge import Edge, Edges, FusionQueue, Message, DataPiece +from ..node import RepeaterNode, SensorNode, FusionNode from stonesoup.types.detection import TrueDetection @@ -603,18 +606,13 @@ def test_fully_propagated(edge_lists, times, ground_truths): for key in node.data_held['created'].keys(): assert len(node.data_held['created'][key]) == 3 - for edge in network.edges.edges: - if len(edge.unsent_data) != 0: - print(f"Node {edge.sender.label} has unsent data:") - print(edge.unsent_data) - # Network should not be fully propagated - # assert network.fully_propagated is False + assert network.fully_propagated is False network.propagate(time_increment=1) # Network should now be fully propagated - # assert network.fully_propagated + assert network.fully_propagated def test_information_arch_propagate(edge_lists, ground_truths, times): @@ -643,3 +641,158 @@ def test_information_arch_init(edge_lists): # Network contains a repeater node, InformationArchitecture should raise a type error. with pytest.raises(TypeError): _ = InformationArchitecture(edges=edges) + + +def test_network_arch(radar_sensors, ground_truths, tracker, track_tracker, times): + start_time = times['start'] + sensor_set = radar_sensors + fq = FusionQueue() + + node_A = SensorNode(sensor=sensor_set[0], label='SensorNode A') + node_B = SensorNode(sensor=sensor_set[2], label='SensorNode B') + + node_C_tracker = copy.deepcopy(tracker) + node_C_tracker.detector = FusionQueue() + node_C = FusionNode(tracker=node_C_tracker, fusion_queue=node_C_tracker.detector, latency=0, + label='FusionNode C') + + ## + node_D = SensorNode(sensor=sensor_set[1], label='SensorNode D') + node_E = SensorNode(sensor=sensor_set[3], label='SensorNode E') + + node_F_tracker = copy.deepcopy(tracker) + node_F_tracker.detector = FusionQueue() + node_F = FusionNode(tracker=node_F_tracker, fusion_queue=node_F_tracker.detector, latency=0) + + node_H = SensorNode(sensor=sensor_set[4]) + + node_G = FusionNode(tracker=track_tracker, fusion_queue=fq, latency=0) + + repeaternode1 = RepeaterNode(label='RepeaterNode 1') + repeaternode2 = RepeaterNode(label='RepeaterNode 2') + + network_arch = NetworkArchitecture( + edges=Edges([Edge((node_A, repeaternode1), edge_latency=0.5), + Edge((repeaternode1, node_C), edge_latency=0.5), + Edge((node_B, node_C)), + Edge((node_A, repeaternode2), edge_latency=0.5), + Edge((repeaternode2, node_C)), + Edge((repeaternode1, repeaternode2)), + Edge((node_D, node_F)), Edge((node_E, node_F)), + Edge((node_C, node_G), edge_latency=0), + Edge((node_F, node_G), edge_latency=0), + Edge((node_H, node_G)) + ]), + current_time=start_time) + + # Check all Nodes are present in the Network Architecture + assert node_A in network_arch.all_nodes + assert node_B in network_arch.all_nodes + assert node_C in network_arch.all_nodes + assert node_D in network_arch.all_nodes + assert node_E in network_arch.all_nodes + assert node_F in network_arch.all_nodes + assert node_G in network_arch.all_nodes + assert node_H in network_arch.all_nodes + assert repeaternode1 in network_arch.all_nodes + assert repeaternode2 in network_arch.all_nodes + assert len(network_arch.all_nodes) == 10 + + # Check Repeater Nodes are not present in the inherited Information Architecture + assert repeaternode1 not in network_arch.information_arch.all_nodes + assert repeaternode2 not in network_arch.information_arch.all_nodes + assert len(network_arch.information_arch.all_nodes) == 8 + + # Check correct number of edges + assert len(network_arch.edges) == 11 + assert len(network_arch.information_arch.edges) == 8 + + # Check time is correct + assert network_arch.current_time == network_arch.information_arch.current_time == start_time + + # Test node 'get' methods work + assert network_arch.repeater_nodes == {repeaternode1, repeaternode2} + assert network_arch.sensor_nodes == {node_A, node_B, node_D, node_E, node_H} + assert network_arch.fusion_nodes == {node_C, node_F, node_G} + + assert network_arch.information_arch.repeater_nodes == set() + assert network_arch.information_arch.sensor_nodes == {node_A, node_B, node_D, node_E, node_H} + assert network_arch.information_arch.fusion_nodes == {node_C, node_F, node_G} + + +def test_net_arch_fully_propagated(edge_lists, times, ground_truths, radar_nodes, radar_sensors, + tracker, track_tracker): + start_time = times['start'] + sensor_set = radar_sensors + fq = FusionQueue() + + node_A = SensorNode(sensor=sensor_set[0], label='SensorNode A') + node_B = SensorNode(sensor=sensor_set[2], label='SensorNode B') + + node_C_tracker = copy.deepcopy(tracker) + node_C_tracker.detector = FusionQueue() + node_C = FusionNode(tracker=node_C_tracker, fusion_queue=node_C_tracker.detector, latency=0, + label='FusionNode C') + + ## + node_D = SensorNode(sensor=sensor_set[1], label='SensorNode D') + node_E = SensorNode(sensor=sensor_set[3], label='SensorNode E') + + node_F_tracker = copy.deepcopy(tracker) + node_F_tracker.detector = FusionQueue() + node_F = FusionNode(tracker=node_F_tracker, fusion_queue=node_F_tracker.detector, latency=0) + + node_H = SensorNode(sensor=sensor_set[4]) + + node_G = FusionNode(tracker=track_tracker, fusion_queue=fq, latency=0) + + repeaternode1 = RepeaterNode(label='RepeaterNode 1') + repeaternode2 = RepeaterNode(label='RepeaterNode 2') + + edges = Edges([Edge((node_A, repeaternode1), edge_latency=0.5), + Edge((repeaternode1, node_C), edge_latency=0.5), + Edge((node_B, node_C)), + Edge((node_A, repeaternode2), edge_latency=0.5), + Edge((repeaternode2, node_C)), + Edge((repeaternode1, repeaternode2)), + Edge((node_D, node_F)), Edge((node_E, node_F)), + Edge((node_C, node_G), edge_latency=0), + Edge((node_F, node_G), edge_latency=0), + Edge((node_H, node_G)) + ]) + + network_arch = NetworkArchitecture( + edges=edges, + current_time=start_time) + + network_arch.measure(ground_truths=ground_truths, noise=True) + + for node in network_arch.sensor_nodes: + # Check that each sensor node has data held for the detection of all 3 targets + for key in node.data_held['created'].keys(): + assert len(node.data_held['created'][key]) == 3 + + # Put some data in a Node's 'messages_to_pass_on' + edge = edges.get((radar_nodes['a'], radar_nodes['c'])) + node = radar_nodes['a'] + message = Message(edge, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, + DataPiece(node, node, 'test_data', datetime.datetime(2016, 1, 2, 3, 4, 5))) + node.messages_to_pass_on.append(message) + + # Network should not be fully propagated + assert network_arch.fully_propagated is False + + network_arch.propagate(time_increment=1) + + # Network should now be fully propagated + assert network_arch.fully_propagated + + +def test_non_propagating_arch(edge_lists, times): + edges = edge_lists['hierarchical_edges'] + start_time = times['start'] + + np_arch = NonPropagatingArchitecture(edges, start_time) + + assert np_arch.current_time == start_time + assert np_arch.edges == edges diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index ddad93c5b..77254ca1a 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -1,5 +1,8 @@ +import datetime + import pytest +from .. import RepeaterNode from ..edge import Edges, Edge, DataPiece, Message, FusionQueue from ...types.track import Track from ...types.time import CompoundTimeRange, TimeRange @@ -40,6 +43,8 @@ def test_edge_init(nodes, times, data_pieces): nodes['b'].latency = 2.0 assert edge.ovr_latency == 1.0 + assert (edge == 10) is False + def test_send_update_message(edges, times, data_pieces): edge = edges['a'] @@ -48,6 +53,9 @@ def test_send_update_message(edges, times, data_pieces): message = Message(edge, times['a'], times['a'], data_pieces['a']) edge.send_message(data_pieces['a'], times['a'], times['a']) + with pytest.raises(TypeError): + edge.send_message('not_a_data_piece', times['a'], times['a']) + assert len(edge.messages_held['pending']) == 1 assert times['a'] in edge.messages_held['pending'] assert len(edge.messages_held['pending'][times['a']]) == 1 @@ -132,3 +140,173 @@ def test_fusion_queue(): b = next(iter_q) assert b == "another item" assert q._to_consume == 1 + + +def test_message_destinations(times, radar_nodes): + start_time = times['start'] + node1 = RepeaterNode(label='n1') + node2 = radar_nodes['a'] + node2.label = 'n2' + node3 = RepeaterNode(label='n3') + edge1 = Edge((node1, node2)) + edge2 = Edge((node1, node3)) + + # Create a message without defining a destination + message1 = Message(edge1, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, + DataPiece(node1, node1, Track([]), + datetime.datetime(2016, 1, 2, 3, 4, 5))) + + # Create a message with node 2 as a destination + message2 = Message(edge1, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, + DataPiece(node1, node1, Track([]), + datetime.datetime(2016, 1, 2, 3, 4, 5)), + destinations={node2}) + + # Create a message with as a defined destination that isn't node 2 + message3 = Message(edge1, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, + DataPiece(node1, node1, Track([]), + datetime.datetime(2016, 1, 2, 3, 4, 5)), + destinations={node3}) + + # Create message that has node2 and node3 as a destination + message4 = Message(edge1, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, + DataPiece(node1, node1, Track([]), + datetime.datetime(2016, 1, 2, 3, 4, 5)), + destinations={node2, node3}) + + # Add messages to node1.messages_to_pass_on and check that unpassed_data() catches it + node1.messages_to_pass_on = [message1, message2, message3, message4] + assert edge1.unpassed_data == [message1, message2, message3, message4] + assert edge2.unpassed_data == [message1, message2, message3, message4] + + # Pass data to edges + for edge in [edge1, edge2]: + for message in edge.unpassed_data: + edge.pass_message(message) + + # Check that no 'unsent' data remains + assert edge1.unsent_data == [] + assert edge2.unsent_data == [] + + # Check that all messages are sent to both edges + assert len(edge1.messages_held['pending'][start_time]) == 4 + assert len(edge2.messages_held['pending'][start_time]) == 4 + + # Check node2 and node3 have no messages to pass on + assert node2.messages_to_pass_on == [] + assert node3.messages_to_pass_on == [] + + # Update both edges + edge1.update_messages(start_time+datetime.timedelta(minutes=1), to_network_node=False) + edge2.update_messages(start_time + datetime.timedelta(minutes=1), to_network_node=True) + + # Check node2.messages_to_pass_on contains message3 that does not have node 2 as a destination + assert len(node2.messages_to_pass_on) == 2 + # Check node3.messages_to_pass_on contains all messages as it is not in information arch + assert len(node3.messages_to_pass_on) == 4 + + # Check that node2 has opened message1 and message3 that were intended to be processed by node3 + data_held = [] + for time in node2.data_held['unfused'].keys(): + data_held += node2.data_held['unfused'][time] + assert len(data_held) == 3 + + +def test_unpassed_data(times): + start_time = times['start'] + node1 = RepeaterNode() + node2 = RepeaterNode() + edge = Edge((node1, node2)) + + # Create a message without defining a destination (send to all) + message = Message(edge, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, + DataPiece(node1, node1, 'test_data', + datetime.datetime(2016, 1, 2, 3, 4, 5))) + + # Add message to node.messages_to_pass_on and check that unpassed_data catches it + node1.messages_to_pass_on.append(message) + assert edge.unpassed_data == [message] + + # Pass message on and check that unpassed_data no longer flags it as unsent + edge.pass_message(message) + assert edge.unpassed_data == [] + + +def test_add(): + node1 = RepeaterNode() + node2 = RepeaterNode() + node3 = RepeaterNode() + + edge1 = Edge((node1, node2)) + edge2 = Edge((node1, node2)) + edge3 = Edge((node2, node3)) + edge4 = Edge((node1, node3)) + + edges = Edges([edge1, edge2, edge3]) + + # Check edges.edges returns all edges + assert edges.edges == [edge1, edge2, edge3] + + # Add an edge and check the change is reflected in edges.edges + edges.add(edge4) + assert edges.edges == [edge1, edge2, edge3, edge4] + + +def test_remove(): + node1 = RepeaterNode() + node2 = RepeaterNode() + node3 = RepeaterNode() + + edge1 = Edge((node1, node2)) + edge2 = Edge((node1, node2)) + edge3 = Edge((node2, node3)) + + edges = Edges([edge1, edge2, edge3]) + + # Check edges.edges returns all edges + assert edges.edges == [edge1, edge2, edge3] + + # Remove an edge and check the change is reflected in edges.edges + edges.remove(edge1) + assert edges.edges == [edge2, edge3] + + +def test_get(): + node1 = RepeaterNode() + node2 = RepeaterNode() + node3 = RepeaterNode() + + edge1 = Edge((node1, node2)) + edge2 = Edge((node1, node2)) + edge3 = Edge((node2, node3)) + + edges = Edges([edge1, edge2, edge3]) + + assert edges.get((node1, node2)) == [edge1, edge2] + assert edges.get((node2, node3)) == [edge3] + assert edges.get((node3, node2)) == [] + assert edges.get((node1, node3)) == [] + + with pytest.raises(ValueError): + edges.get(node_pair=(node1, node2, node3)) + + +def test_pass_message(times): + start_time = times['start'] + node1 = RepeaterNode() + node2 = RepeaterNode() + edge = Edge((node1, node2)) + message = Message(edge, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, + DataPiece(node1, node1, 'test_data', datetime.datetime(2016, 1, 2, 3, 4, 5))) + + node1.messages_to_pass_on.append(message) + + assert node1.messages_to_pass_on == [message] + + edge.pass_message(message) + assert node1.messages_to_pass_on == [message] + assert node2.messages_to_pass_on == [] + assert message in edge.messages_held['pending'][start_time] + assert edge.unpassed_data == [] + + assert (message == 10) is False From c02db32a560ac52d35d1791968bef3933021e5dc Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 6 Nov 2023 16:34:56 +0000 Subject: [PATCH 089/170] Added architecture tutorials: Introduction to Architectures in Stone Soup; Information and Network Architectures --- ...chitectureTutorial_InformationArch_img.png | Bin 0 -> 39869 bytes .../ArchitectureTutorial_NetworkArch_img.png | Bin 0 -> 40557 bytes .../01_Intoduction_to_architectures.py | 122 +++++ ...2_Information_and_Network_Architectures.py | 432 ++++++++++++++++++ .../Intoduction_to_architectures.py | 315 ------------- 5 files changed, 554 insertions(+), 315 deletions(-) create mode 100644 docs/source/_static/ArchitectureTutorial_InformationArch_img.png create mode 100644 docs/source/_static/ArchitectureTutorial_NetworkArch_img.png create mode 100644 docs/tutorials/architecture/01_Intoduction_to_architectures.py create mode 100644 docs/tutorials/architecture/02_Information_and_Network_Architectures.py delete mode 100644 docs/tutorials/architecture/Intoduction_to_architectures.py diff --git a/docs/source/_static/ArchitectureTutorial_InformationArch_img.png b/docs/source/_static/ArchitectureTutorial_InformationArch_img.png new file mode 100644 index 0000000000000000000000000000000000000000..b25e4059d1bb637e65d9763b86eb5b4483360d8d GIT binary patch literal 39869 zcmcG$g4OllLEfpui6zS51Xyu;T7J%ix!b)=yQR!eE$)E61-cf#1nqD;l`MVAmU=|3od$ zc@{8OvZj*kQ*95E^-0piTRKC{Thu`*H`}-q<)r0qbv|CJvDYu1PI|DI;@bWAA1cpY ze-34@@2aao#?8&F7#%jBa@@U){UXuTDVXV8+|lvNPf)(gUx?s z>quBR&LsaE0zb<=?4SzLo!9)9MIvVRMQVBIi`t9HF}G(tFjy-oD^PT+R@#q8Hh*q= zxwTtoUK(6>cB~wBl_+M9B7#XX&b*}ni@ZS=mb9PVmcA1CRF55d1qO>`f)*5Mt!O$= zu5NKh{O@H7S}-pewBGrfQu>mw|17WyZnyHpNZo_RNrRRxoukV-9uKdD zT!q0ZtERCcD zR%kg+kxeidbFiXm6FD^Gn_$Qc9s>!n&{87l&y`I?L1P*QzAcG>zBO1REh3OJ2N1E5 zykUdJz@Lv1s!0|mniKs)ngSZAEEawZn2FYCVgOY~yYF)}j z66J+l>Zn#2EW_?xX>zQB8YZmwVX!pcOfwkqrg%#h{Xf8DgzRLoI}|Z zv{)x1n19LO(d0)0`0i@{#bzTdLU8;`BmR>|GD!yQ#Wk-k*!Hs<94q}YS+e%7m%D~^ za_^`?;@*|6S*}%!I^G(?Jx}91G26(rcFi}jDTuYi?LR=0s3P|Yc4Y@w1d1w$RFdHZ zM{<3hJzzbe(5hl(_BaG5%$B#&*#XEk)VNzDqoE#(KBvH;`fVrzn{TfyT6Fhz;G%

rQ~1&HO7;~JMFx@RZes~y`3sPq+MAbI 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 literal 0 HcmV?d00001 diff --git a/docs/source/_static/ArchitectureTutorial_NetworkArch_img.png b/docs/source/_static/ArchitectureTutorial_NetworkArch_img.png new file mode 100644 index 0000000000000000000000000000000000000000..3f8837f94532c3ac94cbf99a1e6ebf78a0f2612f GIT binary patch literal 40557 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 Date: Thu, 30 Nov 2023 09:04:34 +0000 Subject: [PATCH 090/170] Add use_arival_time property to Achitecture class to allow simulation of recording data timestamps at fuse node. --- stonesoup/architecture/__init__.py | 13 ++++++++++--- stonesoup/architecture/edge.py | 8 +++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index b8fa5f966..b039e7c61 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -34,6 +34,12 @@ class Architecture(Base): doc="If True, the undirected version of the graph must be connected, ie. all nodes should " "be connected via some path. Set this to False to allow an unconnected architecture. " "Default is True") + use_arrival_time: bool = Property( + default=False, + doc="If True, the timestamp on data passed around the network will not be assigned when it " + "is opened by the fusing node - simulating an architecture where time of recording is " + "not registered by the sensor nodes" + ) # Below is no longer required with changes to plot - didn't delete in case we want to revert # to previous method @@ -473,7 +479,7 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): if failed_edges and edge in failed_edges: edge._failed(self.current_time, time_increment) continue # No data passed along these edges - edge.update_messages(self.current_time) + edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) # fuse goes here? for data_piece, time_pertaining in edge.unsent_data: edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) @@ -590,9 +596,10 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): continue # No data passed along these edges if edge.recipient not in self.information_arch.all_nodes: - edge.update_messages(self.current_time, to_network_node=True) + edge.update_messages(self.current_time, to_network_node=True, + use_arrival_time=self.use_arrival_time) else: - edge.update_messages(self.current_time) + edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) # Send available messages from nodes to the edges if edge.sender in self.information_arch.all_nodes: diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 147dba95f..30b7f7461 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -121,10 +121,12 @@ def pass_message(self, message): # message.data_piece.sent_to.remove(self.nodes[0]) message_copy.data_piece.sent_to.add(self.nodes[1]) - def update_messages(self, current_time, to_network_node=False): + def update_messages(self, current_time, to_network_node=False, use_arrival_time=False): """ Updates the category of messages stored in edge.messages_held if latency time has passed. Adds messages that have 'arrived' at recipient to the relevant holding area of the node. + :param use_arrival_time: Bool that is True if arriving data should use arrival time as + it's timestamp :param current_time: Current time in simulation :param to_network_node: Bool that is true if recipient node is not in the information architecture @@ -146,6 +148,10 @@ def update_messages(self, current_time, to_network_node=False): if message.destinations is None: message.destinations = {self.recipient} + # If instructed to use arrival time as timestamp, set that here + if use_arrival_time and hasattr(message.data_piece.data, 'timestamp'): + message.data_piece.data.timestamp = message.arrival_time + # Update node according to inclusion in Information Architecture if not to_network_node and message.destinations == {self.recipient}: # Add data to recipient's data_held From b5596fe5ded059773f43853de8794405344ee194 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 4 Dec 2023 12:11:45 +0000 Subject: [PATCH 091/170] Add metric table generator for comparing two tracks to a truth --- stonesoup/metricgenerator/metrictables.py | 65 +++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/stonesoup/metricgenerator/metrictables.py b/stonesoup/metricgenerator/metrictables.py index c5add46f6..264d2975f 100644 --- a/stonesoup/metricgenerator/metrictables.py +++ b/stonesoup/metricgenerator/metrictables.py @@ -135,3 +135,68 @@ def set_default_descriptions(self): "SIAP ID Correctness": "Fraction of true objects with correct ID assignment", "SIAP ID Ambiguity": "Fraction of true objects with ambiguous ID assignment" } + + +class SiapDiffTableGenerator(SIAPTableGenerator): + metrics2: Collection[MetricGenerator] = Property(doc="Set of metrics to put in the table") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def compute_metric(self, **kwargs): + """Generate table method + + Returns a matplotlib Table of metrics with their descriptions, target + values and a coloured value cell to represent how well the tracker has + performed in relation to each specific metric (red=bad, green=good)""" + + white = (1, 1, 1) + cellText = [["Metric", "Description", "Target", "Track 1 Value", "Track 2 Value", "Diff"]] + cellColors = [[white, white, white, white, white, white]] + + t1_metrics = sorted(self.metrics, key=attrgetter('title')) + t2_metrics = sorted(self.metrics2, key=attrgetter('title')) + + for t1metric, t2metric in zip(t1_metrics, t2_metrics): + # Add metric details to table row + metric_name = t1metric.title + description = self.descriptions[metric_name] + target = self.targets[metric_name] + t1value = t1metric.value + t2value = t2metric.value + diff = round(t1value - t2value, ndigits=2) + cellText.append([metric_name, description, target, "{:.2f}".format(t1value), + "{:.2f}".format(t2value), diff]) + + # Generate color for value cell based on closeness to target value + # Closeness to infinity cannot be represented as a color + if target is not None and not target == np.inf: + red_value1 = 1 + green_value1 = 1 + red_value2 = 1 + green_value2 = 1 + # A value of 1 for both red & green produces yellow + + if abs(t1value - target) < abs(t2value - target): + red_value1 = 0 + green_value2 = 0 + elif abs(t1value - target) > abs(t2value - target): + red_value2 = 0 + green_value1 = 0 + + cellColors.append( + [white, white, white, (red_value1, green_value1, 0, 0.5), + (red_value2, green_value2, 0, 0.5), white]) + else: + cellColors.append([white, white, white, white, white, white]) + + # "Plot" table + scale = (1, 3) + fig = plt.figure(figsize=(len(cellText)*scale[0] + 1, len(cellText[0])*scale[1]/2)) + ax = fig.add_subplot(1, 1, 1) + ax.axis('off') + table = matplotlib.table.table(ax, cellText, cellColors, loc='center') + table.auto_set_column_width([0, 1, 2, 3]) + table.scale(*scale) + + return table \ No newline at end of file From a390776593c59345104c0aadb352f420201f5c33 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 4 Dec 2023 15:53:37 +0000 Subject: [PATCH 092/170] Deepcopy datapieces to allow fusion node to recieve both induvidually. Add data incest tutorial. --- .../architecture/03_Avoiding_Data_Incest.py | 454 ++++++++++++++++++ stonesoup/architecture/__init__.py | 6 +- stonesoup/architecture/edge.py | 5 +- stonesoup/metricgenerator/metrictables.py | 17 +- 4 files changed, 473 insertions(+), 9 deletions(-) create mode 100644 docs/tutorials/architecture/03_Avoiding_Data_Incest.py diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py new file mode 100644 index 000000000..636ec47b8 --- /dev/null +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python +# coding: utf-8 + +""" +======================== +3 - Avoiding Data Incest +======================== +""" + +import tempfile +import random +import copy +import numpy as np +from datetime import datetime, timedelta + + +# %% +# Introduction +# ------------ +# This tutorial uses the Stone Soup Architecture package to provide an example of how data incest +# can occur in a poorly designed network. +# +# We design two architectures: a centralised (non-hierarchical) architecture, and a hierarchical +# alternative, and look to compare the fused results at the central, or 'top-of-the-hierarchy' +# node. +# +# Scenario generation +# ------------------- + +start_time = datetime.now().replace(microsecond=0) +np.random.seed(1990) +random.seed(1990) + +# %% +# "Good" and "Bad" Sensors +# ^^^^^^^^^^^^^^^^^^^^^^^^ +# +# We build a "good" sensor with low measurement noise (high measurement accuracy). + +from stonesoup.types.state import StateVector +from stonesoup.sensor.radar.radar import RadarRotatingBearingRange +from stonesoup.types.angle import Angle + +good_sensor = RadarRotatingBearingRange( + position_mapping=(0, 2), + noise_covar=np.array([[np.radians(0.5) ** 2, 0], + [0, 1 ** 2]]), + ndim_state=4, + position=np.array([[10], [20 - 40]]), + rpm=60, + fov_angle=np.radians(360), + dwell_centre=StateVector([0.0]), + max_range=np.inf, + resolutions={'dwell_centre': Angle(np.radians(30))} + ) + +good_sensor.timestamp = start_time + +# %% +# We then build a third "bad" sensor with high measurement noise (low measurement accuracy). +# This will enable us to design an architecture and observe how the "bad" measurements are +# propagated and fused with the "good" measurements. The bad sensor has its noise covariance +# scaled by a factor of `sf` over the noise of the good sensors. + +sf = 2 +bad_sensor = RadarRotatingBearingRange( + position_mapping=(0, 2), + noise_covar=np.array([[sf * np.radians(0.5) ** 2, 0], + [0, sf * 1 ** 2]]), + ndim_state=4, + position=np.array([[10], [3*20 - 40]]), + rpm=60, + fov_angle=np.radians(360), + dwell_centre=StateVector([0.0]), + max_range=np.inf, + resolutions={'dwell_centre': Angle(np.radians(30))} + ) + +bad_sensor.timestamp = start_time +all_sensors = {good_sensor, bad_sensor} + +# %% +# Ground Truth +# ^^^^^^^^^^^^ + +from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, \ + ConstantVelocity +from stonesoup.types.groundtruth import GroundTruthPath, GroundTruthState +from ordered_set import OrderedSet + +# Generate transition model +transition_model = CombinedLinearGaussianTransitionModel([ConstantVelocity(0.005), + ConstantVelocity(0.005)]) + +yps = range(0, 100, 10) # y value for prior state +truths = OrderedSet() +ntruths = 1 # number of ground truths in simulation +time_max = 60 # timestamps the simulation is observed over +timesteps = [start_time + timedelta(seconds=k) for k in range(time_max)] + +xdirection = 1 +ydirection = 1 + +# Generate ground truths +for j in range(0, ntruths): + truth = GroundTruthPath([GroundTruthState([0, xdirection, yps[j], ydirection], + timestamp=timesteps[0])], id=f"id{j}") + + for k in range(1, time_max): + truth.append( + GroundTruthState(transition_model.function(truth[k - 1], noise=True, + time_interval=timedelta(seconds=1)), + timestamp=timesteps[k])) + truths.add(truth) + + xdirection *= -1 + if j % 2 == 0: + ydirection *= -1 + +# %% +# Build Tracker +# ^^^^^^^^^^^^^ +# +# We use the same configuration of Trackers and Track-Trackers as we did in the previous tutorial. + +from stonesoup.predictor.kalman import KalmanPredictor +from stonesoup.updater.kalman import ExtendedKalmanUpdater +from stonesoup.hypothesiser.distance import DistanceHypothesiser +from stonesoup.measures import Mahalanobis +from stonesoup.dataassociator.neighbour import GNNWith2DAssignment +from stonesoup.deleter.error import CovarianceBasedDeleter +from stonesoup.types.state import GaussianState +from stonesoup.initiator.simple import MultiMeasurementInitiator +from stonesoup.tracker.simple import MultiTargetTracker +from stonesoup.architecture.edge import FusionQueue + +predictor = KalmanPredictor(transition_model) +updater = ExtendedKalmanUpdater(measurement_model=None) +hypothesiser = DistanceHypothesiser(predictor, updater, measure=Mahalanobis(), missed_distance=5) +data_associator = GNNWith2DAssignment(hypothesiser) +deleter = CovarianceBasedDeleter(covar_trace_thresh=3) +initiator = MultiMeasurementInitiator( + prior_state=GaussianState([[0], [0], [0], [0]], np.diag([0, 1, 0, 1])), + measurement_model=None, + deleter=deleter, + data_associator=data_associator, + updater=updater, + min_points=2, + ) + +tracker = MultiTargetTracker(initiator, deleter, None, data_associator, updater) + +# %% +# Track Tracker +# ^^^^^^^^^^^^^ + +from stonesoup.updater.wrapper import DetectionAndTrackSwitchingUpdater +from stonesoup.updater.chernoff import ChernoffUpdater +from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder + +track_updater = ChernoffUpdater(None) +detection_updater = ExtendedKalmanUpdater(None) +detection_track_updater = DetectionAndTrackSwitchingUpdater(None, detection_updater, track_updater) + + +fq = FusionQueue() + +track_tracker = MultiTargetTracker( + initiator, deleter, Tracks2GaussianDetectionFeeder(fq), data_associator, detection_track_updater) + +# %% +# Non-Hierarchical Architecture +# ----------------------------- +# +# This example will consider two different architectures: +# - A non-hierarchical, centralised architecture +# - A hierarchical architecture +# Both architectures will use identical nodes to allow comparison between the two. +# +# We start by constructing the non-hierarchical, centralised architecture. +# +# Nodes +# ^^^^^ + +from stonesoup.architecture.node import SensorNode, FusionNode, SensorFusionNode + +node_B1_tracker = copy.deepcopy(tracker) +node_B1_tracker.detector = FusionQueue() + +node_A1 = SensorNode(sensor=bad_sensor, + label='Bad \n SensorNode', + position=(-1, -1)) +node_B1 = SensorFusionNode(sensor=good_sensor, + label='Good \n SensorFusionNode', + tracker=node_B1_tracker, + fusion_queue=node_B1_tracker.detector, + position=(1, -1)) +node_C1 = FusionNode(tracker=track_tracker, + fusion_queue=fq, + latency=0, + label='FusionNode', + position=(0, 0)) + +# %% +# Edges +# ^^^^^ +# +# Here we define the set of edges for the non hierarchical (NH) architecture. + +from stonesoup.architecture import InformationArchitecture +from stonesoup.architecture.edge import Edge, Edges + +NH_edges = Edges([Edge((node_A1, node_B1), edge_latency=1), + Edge((node_B1, node_C1), edge_latency=0), + Edge((node_A1, node_C1), edge_latency=0)]) + +# %% +# Create the Non-Hierarchical Architecture +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +NH_architecture = InformationArchitecture(NH_edges, current_time=start_time, use_arrival_time=True) +NH_architecture.plot(tempfile.gettempdir(), use_positions=True) + +# %% +# Run the Simulation +# ^^^^^^^^^^^^^^^^^^ + +for time in timesteps: + NH_architecture.measure(truths, noise=True) + NH_architecture.propagate(time_increment=1) + +# %% +# Extract all detections that arrived at Node C +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +from stonesoup.types.detection import TrueDetection + +NH_detections = set() +for timestep in node_C1.data_held['unfused']: + for datapiece in node_C1.data_held['unfused'][timestep]: + if isinstance(datapiece.data, TrueDetection): + NH_detections.add(datapiece.data) + +# %% +# Plot the tracks stored at Node C +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +from stonesoup.plotter import Plotterly + +def reduce_tracks(tracks): + return { + type(track)([s for s in track.last_timestamp_generator()]) + for track in tracks} + +plotter = Plotterly() +plotter.plot_ground_truths(truths, [0, 2]) +plotter.plot_tracks(reduce_tracks(node_C1.tracks), [0, 2], track_label="Node C1", + line=dict(color='#00FF00'), uncertainty=True) +plotter.plot_sensors(all_sensors) +plotter.plot_measurements(NH_detections, [0, 2]) +plotter.fig + +# %% +# Hierarchical Architecture +# ------------------------- +# +# We now create an alternative architecture. We recreate the same set of nodes as before, but +# with a new edge set, which is a subset of the edge set used in the non-hierarchical +# architecture. +# +# Regenerate Nodes identical to those in the non-hierarchical example +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +from stonesoup.architecture.node import SensorNode, FusionNode +fq2 = FusionQueue() + +track_tracker2 = MultiTargetTracker( + initiator, deleter, Tracks2GaussianDetectionFeeder(fq2), data_associator, + detection_track_updater) + +node_B2_tracker = copy.deepcopy(tracker) +node_B2_tracker.detector = FusionQueue() + +node_A2 = SensorNode(sensor=bad_sensor, + label='Bad \n SensorNode', + position=(-1, -1)) +node_B2 = SensorFusionNode(sensor=good_sensor, + label='Good \n SensorFusionNode', + tracker=node_B2_tracker, + fusion_queue=node_B2_tracker.detector, + position=(1, -1)) +node_C2 = FusionNode(tracker=track_tracker2, + fusion_queue=fq2, + latency=0, + label='FusionNode', + position=(0, 0)) + +# %% +# Create Edges forming a Hierarchical Architecture +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +H_edges = Edges([Edge((node_A2, node_C2), edge_latency=0), + Edge((node_B2, node_C2), edge_latency=0)]) + +# %% +# Create the Non-Hierarchical Architecture +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +H_architecture = InformationArchitecture(H_edges, current_time=start_time, use_arrival_time=True) +H_architecture.plot(tempfile.gettempdir(), use_positions=True) + +# %% +# Run the Simulation +# ^^^^^^^^^^^^^^^^^^ + +for time in timesteps: + H_architecture.measure(truths, noise=True) + H_architecture.propagate(time_increment=1) + +# %% +# Extract all detections that arrived at Node C +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +H_detections = set() +for timestep in node_C2.data_held['unfused']: + for datapiece in node_C2.data_held['unfused'][timestep]: + if isinstance(datapiece.data, TrueDetection): + H_detections.add(datapiece.data) + +# %% +# Plot the tracks stored at Node C +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +plotter = Plotterly() +plotter.plot_ground_truths(truths, [0, 2]) +plotter.plot_tracks(reduce_tracks(node_C2.tracks), [0, 2], track_label="Node C2", + line=dict(color='#00FF00'), uncertainty=True) +plotter.plot_sensors(all_sensors) +plotter.plot_measurements(H_detections, [0,2]) +plotter.fig + +# %% +# Metrics +# ------- +# At a glance, the results from the hierarchical architecture look similar to the results from +# the original centralised architecture. We will now calculate and plot some metrics to give an +# insight into the differences. +# +# Calculate SIAP metrics for Centralised Architecture +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +from stonesoup.metricgenerator.tracktotruthmetrics import SIAPMetrics +from stonesoup.measures import Euclidean +from stonesoup.dataassociator.tracktotrack import TrackToTruth +from stonesoup.metricgenerator.manager import MultiManager + +NH_siap_EKF_truth = SIAPMetrics(position_measure=Euclidean((0, 2)), + velocity_measure=Euclidean((1, 3)), + generator_name='NH_SIAP_EKF-truth', + tracks_key='NH_EKF_tracks', + truths_key='truths' + ) + +associator = TrackToTruth(association_threshold=30) + + +# %% + +NH_metric_manager = MultiManager([NH_siap_EKF_truth, + ], associator) # associator for generating SIAP metrics +NH_metric_manager.add_data({'NH_EKF_tracks': node_C1.tracks, + 'truths': truths, + 'NH_detections': NH_detections}, overwrite=False) +NH_metrics = NH_metric_manager.generate_metrics() + + +# %% + +NH_siap_metrics = NH_metrics['NH_SIAP_EKF-truth'] +NH_siap_averages_EKF = {NH_siap_metrics.get(metric) for metric in NH_siap_metrics + if metric.startswith("SIAP") and not metric.endswith(" at times")} + +# %% +# Calculate Metrics for Hierarchical Architecture +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +H_siap_EKF_truth = SIAPMetrics(position_measure=Euclidean((0, 2)), + velocity_measure=Euclidean((1, 3)), + generator_name='H_SIAP_EKF-truth', + tracks_key='H_EKF_tracks', + truths_key='truths' + ) + +associator = TrackToTruth(association_threshold=30) + + +# %% + +H_metric_manager = MultiManager([H_siap_EKF_truth + ], associator) # associator for generating SIAP metrics +H_metric_manager.add_data({'H_EKF_tracks':node_C2.tracks, + 'truths': truths, + 'H_detections': H_detections}, overwrite=False) +H_metrics = H_metric_manager.generate_metrics() + + +# %% + +H_siap_metrics = H_metrics['H_SIAP_EKF-truth'] +H_siap_averages_EKF = {H_siap_metrics.get(metric) for metric in H_siap_metrics + if metric.startswith("SIAP") and not metric.endswith(" at times")} + +# %% +# Compare Metrics +# ^^^^^^^^^^^^^^^ +# +# Below we plot a table of SIAP metrics for both architectures. This results in this comparison +# table show that the results from the hierarchical architecture outperform the results from the +# centralised architecture. +# +# Further below is plot of SIAP position accuracy over time for the duration of the simulation. +# Smaller values represent higher accuracy + +from stonesoup.metricgenerator.metrictables import SiapDiffTableGenerator +SiapDiffTableGenerator(NH_siap_averages_EKF, H_siap_averages_EKF).compute_metric() + + +# %% + +from stonesoup.plotter import MetricPlotter + +combined_metrics = H_metrics | NH_metrics +graph = MetricPlotter() +graph.plot_metrics(combined_metrics, generator_names=['H_SIAP_EKF-truth', + 'NH_SIAP_EKF-truth'], + metric_names=['SIAP Position Accuracy at times'], + color=['red', 'blue']) + +# %% +# Explanation of Results +# ---------------------- +# +# In the centralised architecture, measurements from the 'bad' sensor are passed to both the +# central fusion node (C), and the sensor-fusion node (B). At node B, these measurements are +# fused with measurements from the 'good' sensor, and the output is sent to node C. +# +# At node C, the fused results from node B are once again fused with the measurements from node A. +# This is fusing data into data that already contains elements of itself. Hence we end up with a +# fused result that is bias towards the readings from sensor A. +# +# By altering the architecture through removing the edge from node A to node B, we are removing +# the incestual loop, and the resulting fusion at node C is just fusion of two disjoint sets of +# measurements. Although node C is still recieving the less accurate measurements, its is not +# biased towards the measurements from node A. diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index b039e7c61..702089f60 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -36,9 +36,9 @@ class Architecture(Base): "Default is True") use_arrival_time: bool = Property( default=False, - doc="If True, the timestamp on data passed around the network will not be assigned when it " - "is opened by the fusing node - simulating an architecture where time of recording is " - "not registered by the sensor nodes" + doc="If True, the timestamp on data passed around the network will not be assigned when " + "it is opened by the fusing node - simulating an architecture where time of recording " + "is not registered by the sensor nodes" ) # Below is no longer required with changes to plot - didn't delete in case we want to revert diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 30b7f7461..5bbe6689a 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -98,8 +98,11 @@ def send_message(self, data_piece, time_pertaining, time_sent): raise TypeError("Message info must be one of the following types: " "Detection, Hypothesis or Track") # Add message to 'pending' dict of edge + data_to_send = copy.deepcopy(data_piece.data) + new_datapiece = DataPiece(data_piece.node, data_piece.originator, data_to_send, + data_piece.time_arrived, data_piece.track) message = Message(edge=self, time_pertaining=time_pertaining, time_sent=time_sent, - data_piece=data_piece, destinations={self.recipient}) + data_piece=new_datapiece, destinations={self.recipient}) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) # ensure message not re-sent data_piece.sent_to.add(self.nodes[1]) diff --git a/stonesoup/metricgenerator/metrictables.py b/stonesoup/metricgenerator/metrictables.py index 264d2975f..e7616430b 100644 --- a/stonesoup/metricgenerator/metrictables.py +++ b/stonesoup/metricgenerator/metrictables.py @@ -138,6 +138,10 @@ def set_default_descriptions(self): class SiapDiffTableGenerator(SIAPTableGenerator): + """ + Given two sets of metric generators, the SiapDiffTableGenerator returns a table displaying the + difference between two sets of metrics. Allows quick comparison of two sets of metrics. + """ metrics2: Collection[MetricGenerator] = Property(doc="Set of metrics to put in the table") def __init__(self, *args, **kwargs): @@ -146,12 +150,15 @@ def __init__(self, *args, **kwargs): def compute_metric(self, **kwargs): """Generate table method - Returns a matplotlib Table of metrics with their descriptions, target - values and a coloured value cell to represent how well the tracker has - performed in relation to each specific metric (red=bad, green=good)""" + Returns a matplotlib Table of metrics for two sets of values. Table contains metric + descriptions, target values and a coloured value cell for each set of metrics. + The colour of each value cell represents how the pair of values of the metric compare to + eachother, with the better value showing in green. Table also contains a 'Diff' value + displaying the difference between the pair of metric values.""" white = (1, 1, 1) - cellText = [["Metric", "Description", "Target", "Track 1 Value", "Track 2 Value", "Diff"]] + cellText = [["Metric", "Description", "Target", "Metrics 1 Value", "Metrics 2 Value", + "Diff"]] cellColors = [[white, white, white, white, white, white]] t1_metrics = sorted(self.metrics, key=attrgetter('title')) @@ -199,4 +206,4 @@ def compute_metric(self, **kwargs): table.auto_set_column_width([0, 1, 2, 3]) table.scale(*scale) - return table \ No newline at end of file + return table From 33d2f7208959076ec35379c06a9dce43b6777e9b Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Tue, 5 Dec 2023 16:26:51 +0000 Subject: [PATCH 093/170] Minor changes to docs prose --- .../architecture/03_Avoiding_Data_Incest.py | 14 ++++---------- stonesoup/architecture/__init__.py | 4 ++-- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 636ec47b8..dffb2c5ef 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -17,12 +17,11 @@ # %% # Introduction # ------------ -# This tutorial uses the Stone Soup Architecture package to provide an example of how data incest +# This tutorial uses the Stone Soup Architecture module to provide an example of how data incest # can occur in a poorly designed network. # # We design two architectures: a centralised (non-hierarchical) architecture, and a hierarchical -# alternative, and look to compare the fused results at the central, or 'top-of-the-hierarchy' -# node. +# alternative, and look to compare the fused results at the central node. # # Scenario generation # ------------------- @@ -172,11 +171,6 @@ # Non-Hierarchical Architecture # ----------------------------- # -# This example will consider two different architectures: -# - A non-hierarchical, centralised architecture -# - A hierarchical architecture -# Both architectures will use identical nodes to allow comparison between the two. -# # We start by constructing the non-hierarchical, centralised architecture. # # Nodes @@ -205,7 +199,7 @@ # Edges # ^^^^^ # -# Here we define the set of edges for the non hierarchical (NH) architecture. +# Here we define the set of edges for the non-hierarchical (NH) architecture. from stonesoup.architecture import InformationArchitecture from stonesoup.architecture.edge import Edge, Edges @@ -446,7 +440,7 @@ def reduce_tracks(tracks): # # At node C, the fused results from node B are once again fused with the measurements from node A. # This is fusing data into data that already contains elements of itself. Hence we end up with a -# fused result that is bias towards the readings from sensor A. +# fused result that is biased towards the readings from sensor A. # # By altering the architecture through removing the edge from node A to node B, we are removing # the incestual loop, and the resulting fusion at node C is just fusion of two disjoint sets of diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 702089f60..da7726bce 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -268,7 +268,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, layer = -1 while len(plotted_nodes) < len(self.all_nodes): - # Initialse an empty set to store nodes to be considered in the next iteration + # Initialise an empty set to store nodes to be considered in the next iteration next_layer_nodes = set() # Iterate through nodes on the current layer (nodes that have been plotted but have @@ -352,7 +352,7 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, @property def density(self): - """Returns the density of the graph, ie. the proportion of possible edges between nodes + """Returns the density of the graph, i.e. the proportion of possible edges between nodes that exist in the graph""" num_nodes = len(self.all_nodes) num_edges = len(self.edges) From 04396d58799a740126e7af5e691262daa2e03d94 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 5 Dec 2023 17:31:36 +0000 Subject: [PATCH 094/170] Fix image loading in architecture tutorial 3 --- docs/source/_static/tutorial3img1.png | Bin 0 -> 15877 bytes docs/source/_static/tutorial3img2.png | Bin 0 -> 15548 bytes .../02_Information_and_Network_Architectures.py | 2 +- .../architecture/03_Avoiding_Data_Incest.py | 16 ++++++++++++++-- 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 docs/source/_static/tutorial3img1.png create mode 100644 docs/source/_static/tutorial3img2.png diff --git a/docs/source/_static/tutorial3img1.png b/docs/source/_static/tutorial3img1.png new file mode 100644 index 0000000000000000000000000000000000000000..348671c3f4d0e1eccb2c3e912de8b82ed128d530 GIT binary patch literal 15877 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%0A<3h)_C0VandWbaB4pzf6+-dWgrW?XVg{rY69 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 MrmU@0qhKBOKMqpYYybcN literal 0 HcmV?d00001 diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index f53f91d96..e476a7ad6 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -215,7 +215,7 @@ # %% -# Build and plot Inforamtion Architecture +# Build and plot Information Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ information_architecture = InformationArchitecture(edges, current_time=start_time) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index dffb2c5ef..baea6fc30 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -213,7 +213,12 @@ # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ NH_architecture = InformationArchitecture(NH_edges, current_time=start_time, use_arrival_time=True) -NH_architecture.plot(tempfile.gettempdir(), use_positions=True) +NH_architecture.plot(tempfile.gettempdir(), use_positions=True, save_plot=False) + +# %% +# .. image:: ../../_static/tutorial3img1.png +# :width: 500 +# :alt: Image showing information architecture # %% # Run the Simulation @@ -241,11 +246,13 @@ from stonesoup.plotter import Plotterly + def reduce_tracks(tracks): return { type(track)([s for s in track.last_timestamp_generator()]) for track in tracks} + plotter = Plotterly() plotter.plot_ground_truths(truths, [0, 2]) plotter.plot_tracks(reduce_tracks(node_C1.tracks), [0, 2], track_label="Node C1", @@ -301,7 +308,12 @@ def reduce_tracks(tracks): # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ H_architecture = InformationArchitecture(H_edges, current_time=start_time, use_arrival_time=True) -H_architecture.plot(tempfile.gettempdir(), use_positions=True) +H_architecture.plot(tempfile.gettempdir(), use_positions=True, save_plot=False) + +# %% +# .. image:: ../../_static/tutorial3img2.png +# :width: 500 +# :alt: Image showing information architecture # %% # Run the Simulation From 9c0e7ee92f94b2b35050c41e7d0b7d3ae8b5e2e6 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 11 Dec 2023 16:19:16 +0000 Subject: [PATCH 095/170] Fix grammar for architecture documentation --- .../01_Intoduction_to_architectures.py | 12 +-- ...2_Information_and_Network_Architectures.py | 76 ++++++++++--------- .../architecture/03_Avoiding_Data_Incest.py | 10 +-- 3 files changed, 51 insertions(+), 47 deletions(-) diff --git a/docs/tutorials/architecture/01_Intoduction_to_architectures.py b/docs/tutorials/architecture/01_Intoduction_to_architectures.py index f20de9ed7..ce3f3d933 100644 --- a/docs/tutorials/architecture/01_Intoduction_to_architectures.py +++ b/docs/tutorials/architecture/01_Intoduction_to_architectures.py @@ -3,7 +3,7 @@ """ ============================================== -1 - Introduction to Architecture in Stone Soup +1 - Introduction to Architectures in Stone Soup ============================================== """ @@ -59,7 +59,7 @@ # %% # The Node base class contains several properties. The `latency` property gives functionality to # simulate processing latency at the node. The rest of the properties (label, position, colour, -# shape, font_size, node_dim), are optional and primarily used for graph plotting. +# shape, font_size, node_dim), are used for graph plotting. node_A.colour = '#006494' @@ -99,8 +99,8 @@ # %% # Architecture # ------------ -# Architecture classes manage the simualtion of data propagation across a network. Two -# architecture classes are available in stonesoup: :class:`~.InformationArchitecture` and +# Architecture classes manage the simulation of data propagation across a network. Two +# architecture classes are available in Stone Soup: :class:`~.InformationArchitecture` and # :class:`~.NetworkArchitecture`. Information architecture simulates the architecture of how # information is shared across the network, only considering nodes that create or modify # information. Network architecture simulates the architecture of how data is physically @@ -108,7 +108,7 @@ # any data. # # Architecture classes contain an `edges` property - this must be an :class:`~.Edges` object. -# The `current_time` property of an Architecture class maintains the current time within the +# The `current_time` property of an Architecture instance maintains the current time within the # simulation. By default, this begins at the current time of the operating system. from stonesoup.architecture import InformationArchitecture @@ -119,4 +119,4 @@ # %% # .. image:: ../../_static/architecture_simpleexample.png # :width: 500 -# :alt: Image showing basic example an architecture plot +# :alt: Image showing basic example of an architecture plot diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index e476a7ad6..3e8baeb12 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -18,7 +18,7 @@ # Introduction # ------------ # -# Following on from tutorial 01: Introduction to Architecture in Stone Soup, this tutorial +# Following on from Tutorial 01: Introduction to Architectures in Stone Soup, this tutorial # provides a more in-depth walk through of generating and simulating information and network # architectures. # @@ -28,7 +28,7 @@ # %% -# Set-up variables +# Set up variables # ^^^^^^^^^^^^^^^^ start_time = datetime.now().replace(microsecond=0) @@ -67,7 +67,7 @@ # %% -# Create ground-truth +# Create ground truth # ^^^^^^^^^^^^^^^^^^^ from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, \ @@ -155,8 +155,11 @@ # %% -# Build Track-Tracker +# Build Track Tracker # ^^^^^^^^^^^^^^^^^^^ +# +# The track tracker works by treating tracks as detections, in order to enable fusion between tracks and detections +# together. from stonesoup.updater.wrapper import DetectionAndTrackSwitchingUpdater from stonesoup.updater.chernoff import ChernoffUpdater @@ -267,17 +270,20 @@ def reduce_tracks(tracks): # %% # Network Architecture Example # ---------------------------- -# A network architecture represents the full physical network behind an information architecture. -# Any nodes whose sole purpose is to receive and transmit data onto other nodes in the network. -# These nodes are modelled in the NetworkArchitecture class. +# A network architecture represents the full physical network behind an information architecture. For an analogy, we +# might compare an edge in the information architecture to the connection between a sender and recipient of an email. +# Much of the time, we only care about the information architecture and not the actual mechanisms behind delivery of the +# email, which is similar in nature to the network architecture. +# Some nodes have the sole purpose of receiving and re-transmitting data on to other nodes in the network. We call +# these :class:`~.RepeaterNode`s. Additionally, any :class:`~.Node` present in the :class:`~.InformationArchitecture` +# must also be modelled in the :class:`~.NetworkArchitecture`. # %% # Network Architecture Nodes # ^^^^^^^^^^^^^^^^^^^^^^^^^^ # -# In this example, we will make use of the same set of nodes used in the information -# architecture, with a few additions. - +# In this example, we will add six new :class:`~.RepeaterNode`s to the existing structure to create our corresponding +# :class:`~.NetworkArchitecture`. from stonesoup.architecture.node import RepeaterNode @@ -318,25 +324,25 @@ def reduce_tracks(tracks): # ^^^^^^^^^^^^^^^^^^^^^^^^^^ edges = Edges([Edge((node_A, repeaternode1), edge_latency=0.5), - Edge((repeaternode1, node_C), edge_latency=0.5), - Edge((node_B, repeaternode3)), - Edge((repeaternode3, node_C)), - Edge((node_A, repeaternode2), edge_latency=0.5), - Edge((repeaternode2, node_C)), - Edge((repeaternode1, repeaternode2)), - Edge((node_D, repeaternode4)), - Edge((repeaternode4, node_F)), - Edge((node_E, repeaternode5)), - Edge((repeaternode5, node_F)), - Edge((node_C, node_G), edge_latency=0), - Edge((node_F, node_G), edge_latency=0), - Edge((node_H, repeaternode6)), - Edge((repeaternode6, node_G)) - ]) + Edge((repeaternode1, node_C), edge_latency=0.5), + Edge((node_B, repeaternode3)), + Edge((repeaternode3, node_C)), + Edge((node_A, repeaternode2), edge_latency=0.5), + Edge((repeaternode2, node_C)), + Edge((repeaternode1, repeaternode2)), + Edge((node_D, repeaternode4)), + Edge((repeaternode4, node_F)), + Edge((node_E, repeaternode5)), + Edge((repeaternode5, node_F)), + Edge((node_C, node_G), edge_latency=0), + Edge((node_F, node_G), edge_latency=0), + Edge((node_H, repeaternode6)), + Edge((repeaternode6, node_G)) + ]) # %% -# NetworkArchitecture functionality +# Network Architecture Functionality # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # # A network architecture provides a representation of all nodes in a network - the corresponding @@ -347,14 +353,13 @@ def reduce_tracks(tracks): # architecture that is underpinned by the network architecture. This means that a # NetworkArchitecture object requires two Edge lists: one set of edges representing the # network architecture, and another representing links in the information architecture. To -# ease the set-up of these edge-lists, there are multiple options of how to instanciate a -# NetworkArchitecture class: +# ease the setup of these edge lists, there are multiple options for how to instantiate a :class:`~.NetworkArchitecture` # -# - Firstly, providing the NetworkArchitecture with a network architecture edge-list -# (an Edges object), and a pre-fabricated InformationArchitecture object this must be +# - Firstly, providing the :class:`~.NetworkArchitecture` with an `edge_list` for the network architecture +# (an Edges object), and a pre-fabricated InformationArchitecture object, which must be # provided as property `information_arch`. # -# - Secondly, by providing the NetworkArchitecture with two edge-lists: one for the network +# - Secondly, by providing the NetworkArchitecture with two `edge_list`s: one for the network # architecture and one for the information architecture. # # - Thirdly, by providing just a set of edges for the network architecture. In this case, @@ -366,7 +371,7 @@ def reduce_tracks(tracks): # %% -# Instanciate Network Architecture +# Instantiate Network Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ from stonesoup.architecture import NetworkArchitecture @@ -400,10 +405,9 @@ def reduce_tracks(tracks): # Plot the Network Architecture's Information Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # -# Next we plot the Information Architecture that is underpinned by the Network Architecture. The -# Nodes of the Information Architecture are a subset of the nodes from the Network Architecture. -# The set of edges are theoretical representations of which Nodes in the Information -# Architecture are linked together. An edge in the Information Architecture could be equivalent +# Next we plot the information architecture that is underpinned by the network architecture. The +# nodes of the information architecture are a subset of the nodes from the network architecture. +# An edge in the Information Architecture could be equivalent # to a physical edge between two nodes in the Network Architecture, but it could also be a # representation of multiple edges and nodes that a data piece would be transferred through in # order to pass from a sender to a recipient node. diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index baea6fc30..f72417c27 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -120,7 +120,7 @@ # Build Tracker # ^^^^^^^^^^^^^ # -# We use the same configuration of Trackers and Track-Trackers as we did in the previous tutorial. +# We use the same configuration of trackers and track-trackers as we did in the previous tutorial. from stonesoup.predictor.kalman import KalmanPredictor from stonesoup.updater.kalman import ExtendedKalmanUpdater @@ -447,14 +447,14 @@ def reduce_tracks(tracks): # ---------------------- # # In the centralised architecture, measurements from the 'bad' sensor are passed to both the -# central fusion node (C), and the sensor-fusion node (B). At node B, these measurements are +# central fusion node (C), and the sensor fusion node (B). At node B, these measurements are # fused with measurements from the 'good' sensor, and the output is sent to node C. # -# At node C, the fused results from node B are once again fused with the measurements from node A. -# This is fusing data into data that already contains elements of itself. Hence we end up with a +# At node C, the fused results from node B are once again fused with the measurements from node A, despite implicitly +# containing the information from sensor A already. Hence, we end up with a # fused result that is biased towards the readings from sensor A. # # By altering the architecture through removing the edge from node A to node B, we are removing # the incestual loop, and the resulting fusion at node C is just fusion of two disjoint sets of -# measurements. Although node C is still recieving the less accurate measurements, its is not +# measurements. Although node C is still recieving the less accurate measurements, it is not # biased towards the measurements from node A. From 7958015b105eab7eb29d375975377dbf9c5ca59f Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 11 Dec 2023 16:44:45 +0000 Subject: [PATCH 096/170] minor additional spelling and grammar fixes --- ..._to_architectures.py => 01_Introduction_to_Architectures.py} | 0 docs/tutorials/architecture/03_Avoiding_Data_Incest.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/tutorials/architecture/{01_Intoduction_to_architectures.py => 01_Introduction_to_Architectures.py} (100%) diff --git a/docs/tutorials/architecture/01_Intoduction_to_architectures.py b/docs/tutorials/architecture/01_Introduction_to_Architectures.py similarity index 100% rename from docs/tutorials/architecture/01_Intoduction_to_architectures.py rename to docs/tutorials/architecture/01_Introduction_to_Architectures.py diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index f72417c27..00f188c47 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -456,5 +456,5 @@ def reduce_tracks(tracks): # # By altering the architecture through removing the edge from node A to node B, we are removing # the incestual loop, and the resulting fusion at node C is just fusion of two disjoint sets of -# measurements. Although node C is still recieving the less accurate measurements, it is not +# measurements. Although node C is still receiving the less accurate measurements, it is not # biased towards the measurements from node A. From b5a7c152ade88569e6842b2137592250319ea428 Mon Sep 17 00:00:00 2001 From: jwright2 Date: Tue, 12 Dec 2023 09:18:05 +0000 Subject: [PATCH 097/170] Update CircleCI build to include architectures dependencies --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1b9e549fa..873f74ed4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,7 +24,7 @@ jobs: python -m venv venv . venv/bin/activate pip install --upgrade pip - pip install -e .[dev,orbital] opencv-python-headless + pip install -e .[dev,orbital,architectures] opencv-python-headless - save_cache: paths: - ./venv @@ -72,7 +72,7 @@ jobs: . venv/bin/activate pip install --upgrade pip pip install -r docs/ci-requirements.txt - pip install -e .[dev,orbital] opencv-python-headless + pip install -e .[dev,orbital,architectures] opencv-python-headless - save_cache: paths: - ./venv From f0db7b301475f14d341a7afa73cf49288779715f Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 15 Dec 2023 18:15:07 +0000 Subject: [PATCH 098/170] Clean up comments and documentation. Get rid of an unnecessary function --- stonesoup/architecture/__init__.py | 62 ++----------------- stonesoup/architecture/_functions.py | 2 + stonesoup/architecture/edge.py | 11 ++-- stonesoup/architecture/node.py | 28 +++++---- .../architecture/tests/test_architecture.py | 23 ------- 5 files changed, 27 insertions(+), 99 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index da7726bce..773e94661 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -17,6 +17,9 @@ class Architecture(Base): + """Abstract Architecture Base class. Subclasses must implement the :meth:`~Architecture.propogate` method. + """ + edges: Edges = Property( doc="An Edges object containing all edges. For A to be connected to B we would have an " "Edge with edge_pair=(A, B) in this object.") @@ -41,15 +44,6 @@ class Architecture(Base): "is not registered by the sensor nodes" ) - # Below is no longer required with changes to plot - didn't delete in case we want to revert - # to previous method - # font_size: int = Property( - # default=8, - # doc='Font size for node labels') - # node_dim: tuple = Property( - # default=(0.5, 0.5), - # doc='Height and width of nodes for graph icons, default is (0.5, 0.5)') - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.name: @@ -112,25 +106,12 @@ def shortest_path_dict(self): dpath = {x[0]: x[1] for x in path} return dpath - def _recipient_position(self, node: Node): - """Returns a tuple of (x_coord, y_coord) giving the location of a node's recipient. - If the node has more than one recipient, a ValueError will be raised. """ - recipients = self.recipients(node) - if len(recipients) == 0: - raise ValueError("Node has no recipients") - elif len(recipients) == 1: - recipient = recipients.pop() - else: - raise ValueError("Node has more than one recipient") - return recipient.position - @property def top_level_nodes(self): """Returns a list of nodes with no recipients""" top_nodes = set() for node in self.all_nodes: if len(self.recipients(node)) == 0: - # This node must be the top level node top_nodes.add(node) return top_nodes @@ -234,7 +215,6 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, being displayed. :param plot_style: String providing a style to be used to plot the graph. Currently only one option for plot style given by plot_style = 'hierarchical'. - :return: """ if use_positions: for node in self.di_graph.nodes: @@ -363,7 +343,7 @@ def density(self): def is_hierarchical(self): """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`. Uses the following logic: An architecture is hierarchical if and only if there exists only - one node with 0 recipients, all other nodes have exactly 1 recipient.""" + one node with 0 recipients and all other nodes have exactly 1 recipient.""" if not len(self.top_level_nodes) == 1: return False for node in self.all_nodes: @@ -455,16 +435,6 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): all_detections[sensor_node].add(detection) - # Borrowed below from SensorSuite. I don't think it's necessary, but might be something - # we need. If so, will need to define self.attributes_inform - - # attributes_dict = \ - # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) - # for attribute_name in self.attributes_inform} - # - # for detection in all_detections[sensor_node]: - # detection.metadata.update(attributes_dict) - for data in all_detections[sensor_node]: # The sensor acquires its own data instantly sensor_node.update(data.timestamp, data.timestamp, @@ -496,8 +466,6 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): fuse_node.fuse() self.current_time += timedelta(seconds=time_increment) - for fusion_node in self.fusion_nodes: - pass # fusion_node.tracker.set_time(self.current_time) class NetworkArchitecture(Architecture): @@ -516,7 +484,7 @@ def __init__(self, *args, **kwargs): if self.information_architecture_edges is None: # If repeater nodes are present in the Network architecture, we can deduce an - # information architecture + # Information architecture if len(self.repeater_nodes) > 0: self.information_architecture_edges = Edges(inherit_edges(Edges(self.edges))) self.information_arch = InformationArchitecture( @@ -543,14 +511,6 @@ def __init__(self, *args, **kwargs): "height": f"{node.node_dim[1]}", "fixedsize": True} self.di_graph.nodes[node].update(attr) - # def propagate(self, time_increment: float): - # # Still have to deal with latency/bandwidth - # self.current_time += timedelta(seconds=time_increment) - # for node in self.all_nodes: - # for recipient in self.recipients(node): - # for data in node.data_held: - # recipient.update(self.current_time, data) - def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ @@ -568,16 +528,6 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): all_detections[sensor_node].add(detection) - # Borrowed below from SensorSuite. I don't think it's necessary, but might be something - # we need. If so, will need to define self.attributes_inform - - # attributes_dict = \ - # {attribute_name: sensor_node.sensor.__getattribute__(attribute_name) - # for attribute_name in self.attributes_inform} - # - # for detection in all_detections[sensor_node]: - # detection.metadata.update(attributes_dict) - for data in all_detections[sensor_node]: # The sensor acquires its own data instantly sensor_node.update(data.timestamp, data.timestamp, @@ -622,8 +572,6 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): fuse_node.fuse() self.current_time += timedelta(seconds=time_increment) - for fusion_node in self.fusion_nodes: - pass # fusion_node.tracker.set_time(self.current_time) @property def fully_propagated(self): diff --git a/stonesoup/architecture/_functions.py b/stonesoup/architecture/_functions.py index 81aef41a4..7f50da2ff 100644 --- a/stonesoup/architecture/_functions.py +++ b/stonesoup/architecture/_functions.py @@ -41,6 +41,8 @@ def _default_label(node, last_letters): def _default_letters(type_letters) -> str: + """Utility function to work out the letters which go in the default label as part of the :meth:`~._default_label` + method""" if type_letters == '': return 'A' count = 0 diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 5bbe6689a..1156b3dfe 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -48,7 +48,7 @@ def get(self, *args, **kwargs): class DataPiece(Base): """A piece of data for use in an architecture. Sent via a :class:`~.Message`, - and stored in a Node's data_held""" + and stored in a Node's :attr:`data_held`""" node: "Node" = Property( doc="The Node this data piece belongs to") originator: "Node" = Property( @@ -69,7 +69,7 @@ def __init__(self, *args, **kwargs): class Edge(Base): - """Comprised of two connected Nodes""" + """Comprised of two connected :class:`~.Node`s""" nodes: Tuple["Node", "Node"] = Property(doc="A pair of nodes in the form (sender, recipient)") edge_latency: float = Property(doc="The latency stemming from the edge itself, " "and not either of the nodes", @@ -92,7 +92,6 @@ def send_message(self, data_piece, time_pertaining, time_sent): would be the time of the Detection, or for a Track this is the time of the last State in the Track :param time_sent: Time at which the message was sent - :return: None """ if not isinstance(data_piece, DataPiece): raise TypeError("Message info must be one of the following types: " @@ -112,7 +111,6 @@ def pass_message(self, message): Takes a message from a Node's 'messages_to_pass_on' store and propagates them to the relevant edges. :param message: Message to propagate - :return: None """ message_copy = copy.copy(message) message_copy.edge = self @@ -133,7 +131,6 @@ def update_messages(self, current_time, to_network_node=False, use_arrival_time= :param current_time: Current time in simulation :param to_network_node: Bool that is true if recipient node is not in the information architecture - :return: None """ # Check info type is what we expect to_remove = set() # Needed as we can't change size of a set during iteration @@ -196,7 +193,7 @@ def recipient(self): @property def ovr_latency(self): - """Overall latency including the two Nodes and the edge latency.""" + """Overall latency of this :class:`~.Edge`""" return self.sender.latency + self.edge_latency @property @@ -232,7 +229,7 @@ def __hash__(self): class Edges(Base, Collection): - """Container class for Edge""" + """Container class for :class:`~.Edge`""" edges: List[Edge] = Property(doc="List of Edge objects", default=None) def __init__(self, *args, **kwargs): diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 8631af015..2ccd8c6fe 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -15,28 +15,30 @@ class Node(Base): - """Base node class. Should be abstract""" + """Base Node class. Generally a subclass should be used. Note that most user-defined properties are for + graphical use only, all with default values. """ latency: float = Property( - doc="Contribution to edge latency stemming from this node", + doc="Contribution to edge latency stemming from this node. Default is 0.0", default=0.0) label: str = Property( - doc="Label to be displayed on graph", + doc="Label to be displayed on graph. Default is to label by class and then " + "differentiate via alphabetical labels", default=None) position: Tuple[float] = Property( default=None, - doc="Cartesian coordinates for node") + doc="Cartesian coordinates for node. Determined automatically by default") colour: str = Property( default='#909090', - doc='Colour to be displayed on graph') + doc='Colour to be displayed on graph. Default is grey') shape: str = Property( default='rectangle', - doc='Shape used to display nodes') + doc='Shape used to display nodes. Default is a rectangle') font_size: int = Property( default=5, - doc='Font size for node labels') + doc='Font size for node labels. Default is 5') node_dim: tuple = Property( default=(0.5, 0.5), - doc='Width and height of nodes for graph icons, default is (0.5, 0.5)') + doc='Width and height of nodes for graph icons. Default is (0.5, 0.5)') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -44,6 +46,7 @@ def __init__(self, *args, **kwargs): self.messages_to_pass_on = [] def update(self, time_pertaining, time_arrived, data_piece, category, track=None): + """Updates this :class:`~.Node`'s :attr:`~.data_held` using a new data piece. """ if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): raise TypeError("Times must be datetime objects") if not track: @@ -68,7 +71,7 @@ def update(self, time_pertaining, time_arrived, data_piece, category, track=None class SensorNode(Node): - """A node corresponding to a Sensor. Fresh data is created here""" + """A :class:`~.Node` corresponding to a :class:`~.Sensor`. Fresh data is created here""" sensor: Sensor = Property(doc="Sensor corresponding to this node") colour: str = Property( default='#006eff', @@ -82,7 +85,7 @@ class SensorNode(Node): class FusionNode(Node): - """A node that does not measure new data, but does process data it receives""" + """A :class:`~.Node` that does not measure new data, but does process data it receives""" # feeder probably as well tracker: Tracker = Property( doc="Tracker used by this Node to fuse together Tracks and Detections") @@ -146,7 +149,7 @@ def _track_thread(tracker, input_queue, output_queue): class SensorFusionNode(SensorNode, FusionNode): - """A node that is both a sensor and also processes data""" + """A :class:`~.Node` that is both a :class:`~.Sensor` and also processes data""" colour: str = Property( default='#fc9000', doc='Colour to be displayed on graph. Default is the hex colour code #fc9000') @@ -159,7 +162,8 @@ class SensorFusionNode(SensorNode, FusionNode): class RepeaterNode(Node): - """A node which simply passes data along to others, without manipulating the data itself. """ + """A :class:`~.Node` which simply passes data along to others, without manipulating the data itself. Consequently, + :class:`~.RepeaterNode`s are only used within a :class:`~.NetworkArchitecture`""" colour: str = Property( default='#909090', doc='Colour to be displayed on graph. Default is the hex colour code #909090') diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index b65fe582e..f452c7235 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -282,29 +282,6 @@ def test_shortest_path_dict(nodes, edge_lists): _ = disconnected_arch.shortest_path_dict[nodes['s3']][nodes['s4']] -def test_recipient_position(nodes, tmpdir, edge_lists): - - centralised_edges = edge_lists["centralised_edges"] - - centralised_arch = InformationArchitecture(edges=centralised_edges) - centralised_arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, - plot_style='hierarchical') - - # Check types of _recipient_position output is correct - assert type(centralised_arch._recipient_position(nodes['s4'])) == tuple - assert type(centralised_arch._recipient_position(nodes['s4'])[0]) == float - assert type(centralised_arch._recipient_position(nodes['s4'])[1]) == int - - # Check that finding the position of the recipient node of a node with no recipient raises an - # error - with pytest.raises(ValueError): - centralised_arch._recipient_position(nodes['s1']) - - # Check that calling _recipient_position on a node with multiple recipients raises an error - with pytest.raises(ValueError): - centralised_arch._recipient_position(nodes['s7']) - - def test_top_level_nodes(nodes, edge_lists): simple_edges = edge_lists["simple_edges"] hierarchical_edges = edge_lists["hierarchical_edges"] From 7ecbe2a26752ab6caed1b5b70f3d0f85b288de9d Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 15 Dec 2023 18:26:56 +0000 Subject: [PATCH 099/170] Fix minor bug in typing --- stonesoup/architecture/edge.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 1156b3dfe..5c0b71f75 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -1,6 +1,6 @@ import copy from collections.abc import Collection -from typing import Union, Tuple, List, TYPE_CHECKING +from typing import Union, Tuple, List, Set, TYPE_CHECKING from numbers import Number from datetime import datetime, timedelta from queue import Queue @@ -110,7 +110,7 @@ def pass_message(self, message): """ Takes a message from a Node's 'messages_to_pass_on' store and propagates them to the relevant edges. - :param message: Message to propagate + :param message: :class:`~.Message` to propagate """ message_copy = copy.copy(message) message_copy.edge = self @@ -285,7 +285,7 @@ class Message(Base): doc="Time at which the message was sent") data_piece: DataPiece = Property( doc="Info that the sent message contains") - destinations: set["Node"] = Property(doc="Nodes in the information architecture that the " + destinations: Set["Node"] = Property(doc="Nodes in the information architecture that the " "message is being sent to", default=None) From f88368deef4437735f08099bb4719d4aef85184a Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 21 Dec 2023 09:32:38 +0000 Subject: [PATCH 100/170] Fix failing test by changing method of adjusting data timestamps when architecture property use_arrival_time is True. --- stonesoup/architecture/__init__.py | 3 ++- stonesoup/architecture/_functions.py | 3 ++- stonesoup/architecture/edge.py | 22 ++++++++++------------ stonesoup/architecture/node.py | 21 +++++++++++++++------ 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 773e94661..129f0bee3 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -17,7 +17,8 @@ class Architecture(Base): - """Abstract Architecture Base class. Subclasses must implement the :meth:`~Architecture.propogate` method. + """Abstract Architecture Base class. Subclasses must implement the + :meth:`~Architecture.propogate` method. """ edges: Edges = Property( diff --git a/stonesoup/architecture/_functions.py b/stonesoup/architecture/_functions.py index 7f50da2ff..08ffad62d 100644 --- a/stonesoup/architecture/_functions.py +++ b/stonesoup/architecture/_functions.py @@ -41,7 +41,8 @@ def _default_label(node, last_letters): def _default_letters(type_letters) -> str: - """Utility function to work out the letters which go in the default label as part of the :meth:`~._default_label` + """Utility function to work out the letters which go in the default label as part of the + :meth:`~._default_label` method""" if type_letters == '': return 'A' diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 5c0b71f75..4d9003ddf 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -97,11 +97,11 @@ def send_message(self, data_piece, time_pertaining, time_sent): raise TypeError("Message info must be one of the following types: " "Detection, Hypothesis or Track") # Add message to 'pending' dict of edge - data_to_send = copy.deepcopy(data_piece.data) - new_datapiece = DataPiece(data_piece.node, data_piece.originator, data_to_send, - data_piece.time_arrived, data_piece.track) + # data_to_send = copy.deepcopy(data_piece.data) + # new_datapiece = DataPiece(data_piece.node, data_piece.originator, data_to_send, + # data_piece.time_arrived, data_piece.track) message = Message(edge=self, time_pertaining=time_pertaining, time_sent=time_sent, - data_piece=new_datapiece, destinations={self.recipient}) + data_piece=data_piece, destinations={self.recipient}) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) # ensure message not re-sent data_piece.sent_to.add(self.nodes[1]) @@ -126,8 +126,8 @@ def update_messages(self, current_time, to_network_node=False, use_arrival_time= """ Updates the category of messages stored in edge.messages_held if latency time has passed. Adds messages that have 'arrived' at recipient to the relevant holding area of the node. - :param use_arrival_time: Bool that is True if arriving data should use arrival time as - it's timestamp + # :param use_arrival_time: Bool that is True if arriving data should use arrival time as + # it's timestamp :param current_time: Current time in simulation :param to_network_node: Bool that is true if recipient node is not in the information architecture @@ -148,22 +148,20 @@ def update_messages(self, current_time, to_network_node=False, use_arrival_time= if message.destinations is None: message.destinations = {self.recipient} - # If instructed to use arrival time as timestamp, set that here - if use_arrival_time and hasattr(message.data_piece.data, 'timestamp'): - message.data_piece.data.timestamp = message.arrival_time - # Update node according to inclusion in Information Architecture if not to_network_node and message.destinations == {self.recipient}: # Add data to recipient's data_held self.recipient.update(message.time_pertaining, message.arrival_time, - message.data_piece, "unfused") + message.data_piece, "unfused", + use_arrival_time=use_arrival_time) elif not to_network_node and self.recipient in message.destinations: # Add data to recipient's data held, and message to messages_to_pass_on self.recipient.update(message.time_pertaining, message.arrival_time, - message.data_piece, "unfused") + message.data_piece, "unfused", + use_arrival_time=use_arrival_time) message.destinations = None self.recipient.messages_to_pass_on.append(message) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 2ccd8c6fe..f62040763 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -15,8 +15,8 @@ class Node(Base): - """Base Node class. Generally a subclass should be used. Note that most user-defined properties are for - graphical use only, all with default values. """ + """Base Node class. Generally a subclass should be used. Note that most user-defined + properties are for graphical use only, all with default values. """ latency: float = Property( doc="Contribution to edge latency stemming from this node. Default is 0.0", default=0.0) @@ -45,7 +45,8 @@ def __init__(self, *args, **kwargs): self.data_held = {"fused": {}, "created": {}, "unfused": {}} self.messages_to_pass_on = [] - def update(self, time_pertaining, time_arrived, data_piece, category, track=None): + def update(self, time_pertaining, time_arrived, data_piece, category, track=None, + use_arrival_time=False): """Updates this :class:`~.Node`'s :attr:`~.data_held` using a new data piece. """ if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): raise TypeError("Times must be datetime objects") @@ -64,7 +65,14 @@ def update(self, time_pertaining, time_arrived, data_piece, category, track=None added, self.data_held[category] = _dict_set(self.data_held[category], new_data_piece, time_pertaining) - if isinstance(self, FusionNode) and category in ("created", "unfused"): + + if use_arrival_time and isinstance(self, FusionNode) and \ + category in ("created", "unfused"): + data = copy.copy(data_piece.data) + data.timestamp = time_arrived + self.fusion_queue.put((time_pertaining, {data})) + + elif isinstance(self, FusionNode) and category in ("created", "unfused"): self.fusion_queue.put((time_pertaining, {data_piece.data})) return added @@ -162,8 +170,9 @@ class SensorFusionNode(SensorNode, FusionNode): class RepeaterNode(Node): - """A :class:`~.Node` which simply passes data along to others, without manipulating the data itself. Consequently, - :class:`~.RepeaterNode`s are only used within a :class:`~.NetworkArchitecture`""" + """A :class:`~.Node` which simply passes data along to others, without manipulating the + data itself. Consequently, :class:`~.RepeaterNode`s are only used within a + :class:`~.NetworkArchitecture`""" colour: str = Property( default='#909090', doc='Colour to be displayed on graph. Default is the hex colour code #909090') From d79dfbbad514efde7e07aee2f48ad9fee8c0a2f4 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 21 Dec 2023 09:46:55 +0000 Subject: [PATCH 101/170] Fix short underlines causing bug in architectures tutorials --- .../01_Introduction_to_Architectures.py | 4 ++-- .../02_Information_and_Network_Architectures.py | 16 +++++++++------- .../architecture/03_Avoiding_Data_Incest.py | 6 +++--- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/tutorials/architecture/01_Introduction_to_Architectures.py b/docs/tutorials/architecture/01_Introduction_to_Architectures.py index ce3f3d933..8f85a4ed9 100644 --- a/docs/tutorials/architecture/01_Introduction_to_Architectures.py +++ b/docs/tutorials/architecture/01_Introduction_to_Architectures.py @@ -2,9 +2,9 @@ # coding: utf-8 """ -============================================== +=============================================== 1 - Introduction to Architectures in Stone Soup -============================================== +=============================================== """ import tempfile diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 3e8baeb12..6a250447d 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -158,8 +158,8 @@ # Build Track Tracker # ^^^^^^^^^^^^^^^^^^^ # -# The track tracker works by treating tracks as detections, in order to enable fusion between tracks and detections -# together. +# The track tracker works by treating tracks as detections, in order to enable fusion between +# tracks and detections together. from stonesoup.updater.wrapper import DetectionAndTrackSwitchingUpdater from stonesoup.updater.chernoff import ChernoffUpdater @@ -343,7 +343,7 @@ def reduce_tracks(tracks): # %% # Network Architecture Functionality -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # # A network architecture provides a representation of all nodes in a network - the corresponding # information architecture is made up of a subset of these nodes. @@ -353,11 +353,12 @@ def reduce_tracks(tracks): # architecture that is underpinned by the network architecture. This means that a # NetworkArchitecture object requires two Edge lists: one set of edges representing the # network architecture, and another representing links in the information architecture. To -# ease the setup of these edge lists, there are multiple options for how to instantiate a :class:`~.NetworkArchitecture` +# ease the setup of these edge lists, there are multiple options for how to instantiate a +# :class:`~.NetworkArchitecture` # -# - Firstly, providing the :class:`~.NetworkArchitecture` with an `edge_list` for the network architecture -# (an Edges object), and a pre-fabricated InformationArchitecture object, which must be -# provided as property `information_arch`. +# - Firstly, providing the :class:`~.NetworkArchitecture` with an `edge_list` for the network +# architecture (an Edges object), and a pre-fabricated InformationArchitecture object, which must +# be provided as property `information_arch`. # # - Secondly, by providing the NetworkArchitecture with two `edge_list`s: one for the network # architecture and one for the information architecture. @@ -368,6 +369,7 @@ def reduce_tracks(tracks): # (the lowest latency) between each set of nodes in the architecture. Warning: this method is for # ease of use, and may not represent the information architecture you are designing - it is # best to check by plotting the generated information architecture. +# # %% diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 00f188c47..fbca3cb97 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -270,7 +270,7 @@ def reduce_tracks(tracks): # architecture. # # Regenerate Nodes identical to those in the non-hierarchical example -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ from stonesoup.architecture.node import SensorNode, FusionNode fq2 = FusionQueue() @@ -450,8 +450,8 @@ def reduce_tracks(tracks): # central fusion node (C), and the sensor fusion node (B). At node B, these measurements are # fused with measurements from the 'good' sensor, and the output is sent to node C. # -# At node C, the fused results from node B are once again fused with the measurements from node A, despite implicitly -# containing the information from sensor A already. Hence, we end up with a +# At node C, the fused results from node B are once again fused with the measurements from node +# A, despite implicitly containing the information from sensor A already. Hence, we end up with a # fused result that is biased towards the readings from sensor A. # # By altering the architecture through removing the edge from node A to node B, we are removing From 6bcf76ff7746930a9e54bd0b8f002a3e9323af22 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 21 Dec 2023 12:03:57 +0000 Subject: [PATCH 102/170] Change Edge.unsent_data() method to consider edges rather than recipient nodes. Simplify architecture propagate recursion. --- stonesoup/architecture/__init__.py | 16 ++++++++-------- stonesoup/architecture/edge.py | 13 ++++++------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 129f0bee3..7e7f36131 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -457,16 +457,16 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): # for node in self.processing_nodes: # node.process() # This should happen when a new message is received - count = 0 - if not self.fully_propagated: - count += 1 - self.propagate(time_increment, failed_edges) - return - for fuse_node in self.fusion_nodes: - fuse_node.fuse() + if self.fully_propagated: + for fuse_node in self.fusion_nodes: + fuse_node.fuse() - self.current_time += timedelta(seconds=time_increment) + self.current_time += timedelta(seconds=time_increment) + return + + else: + self.propagate(time_increment, failed_edges) class NetworkArchitecture(Architecture): diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 4d9003ddf..8e1a026d6 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -104,7 +104,7 @@ def send_message(self, data_piece, time_pertaining, time_sent): data_piece=data_piece, destinations={self.recipient}) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) # ensure message not re-sent - data_piece.sent_to.add(self.nodes[1]) + data_piece.sent_to.add(self) def pass_message(self, message): """ @@ -119,15 +119,14 @@ def pass_message(self, message): _, self.messages_held = _dict_set(self.messages_held, message_copy, 'pending', message_copy.time_sent) # Message not opened by repeater node, remove node from 'sent_to' - # message.data_piece.sent_to.remove(self.nodes[0]) - message_copy.data_piece.sent_to.add(self.nodes[1]) + message_copy.data_piece.sent_to.add(self) def update_messages(self, current_time, to_network_node=False, use_arrival_time=False): """ Updates the category of messages stored in edge.messages_held if latency time has passed. Adds messages that have 'arrived' at recipient to the relevant holding area of the node. - # :param use_arrival_time: Bool that is True if arriving data should use arrival time as - # it's timestamp + :param use_arrival_time: Bool that is True if arriving data should use arrival time as + it's timestamp :param current_time: Current time in simulation :param to_network_node: Bool that is true if recipient node is not in the information architecture @@ -198,7 +197,7 @@ def ovr_latency(self): def unpassed_data(self): unpassed = [] for message in self.sender.messages_to_pass_on: - if self.recipient not in message.data_piece.sent_to: + if self not in message.data_piece.sent_to: unpassed.append(message) return unpassed @@ -213,7 +212,7 @@ def unsent_data(self): for time_pertaining in self.sender.data_held[status]: for data_piece in self.sender.data_held[status][time_pertaining]: # Data will be sent to any nodes it hasn't been sent to before - if self.recipient not in data_piece.sent_to: + if self not in data_piece.sent_to: unsent.append((data_piece, time_pertaining)) return unsent From 6aa9a4b85fc97ff7dda049c7ea3f42f40737d9da Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 21 Dec 2023 14:05:28 +0000 Subject: [PATCH 103/170] Filter for duplicate data on fusion queue, update network arch propagate function --- stonesoup/architecture/__init__.py | 15 +++++++-------- stonesoup/architecture/edge.py | 1 + stonesoup/architecture/node.py | 8 ++++++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 7e7f36131..deee4c672 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -563,16 +563,15 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): # for node in self.processing_nodes: # node.process() # This should happen when a new message is received - count = 0 - if not self.fully_propagated: - count += 1 - self.propagate(time_increment, failed_edges) - return + if self.fully_propagated: + for fuse_node in self.fusion_nodes: + fuse_node.fuse() - for fuse_node in self.fusion_nodes: - fuse_node.fuse() + self.current_time += timedelta(seconds=time_increment) + return - self.current_time += timedelta(seconds=time_increment) + else: + self.propagate(time_increment, failed_edges) @property def fully_propagated(self): diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 8e1a026d6..9d47af09e 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -25,6 +25,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._to_consume = 0 self._consuming = False + self.received = set() def _put(self, *args, **kwargs): super()._put(*args, **kwargs) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index f62040763..faeb7e369 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -70,10 +70,14 @@ def update(self, time_pertaining, time_arrived, data_piece, category, track=None category in ("created", "unfused"): data = copy.copy(data_piece.data) data.timestamp = time_arrived - self.fusion_queue.put((time_pertaining, {data})) + if data not in self.fusion_queue.received: + self.fusion_queue.received.add(data) + self.fusion_queue.put((time_pertaining, {data})) elif isinstance(self, FusionNode) and category in ("created", "unfused"): - self.fusion_queue.put((time_pertaining, {data_piece.data})) + if data_piece.data not in self.fusion_queue.received: + self.fusion_queue.received.add(data_piece.data) + self.fusion_queue.put((time_pertaining, {data_piece.data})) return added From 031758934ea21642acd8110df031614977982b1d Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Mon, 22 Jan 2024 14:53:06 +0000 Subject: [PATCH 104/170] Simplify some architecture plotting and enable display in docs --- .../01_Introduction_to_Architectures.py | 9 +- ...2_Information_and_Network_Architectures.py | 25 +-- .../architecture/03_Avoiding_Data_Incest.py | 38 +--- stonesoup/architecture/__init__.py | 204 +++++++----------- stonesoup/architecture/node.py | 21 +- .../architecture/tests/test_architecture.py | 48 ++--- stonesoup/architecture/tests/test_node.py | 6 +- 7 files changed, 112 insertions(+), 239 deletions(-) diff --git a/docs/tutorials/architecture/01_Introduction_to_Architectures.py b/docs/tutorials/architecture/01_Introduction_to_Architectures.py index 8f85a4ed9..415416436 100644 --- a/docs/tutorials/architecture/01_Introduction_to_Architectures.py +++ b/docs/tutorials/architecture/01_Introduction_to_Architectures.py @@ -7,8 +7,6 @@ =============================================== """ -import tempfile - # %% # Introduction # ------------ @@ -114,9 +112,4 @@ from stonesoup.architecture import InformationArchitecture arch = InformationArchitecture(edges=edges) -arch.plot(tempfile.gettempdir(), save_plot=False) - -# %% -# .. image:: ../../_static/architecture_simpleexample.png -# :width: 500 -# :alt: Image showing basic example of an architecture plot +arch \ No newline at end of file diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 6a250447d..b8dc5b644 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -7,7 +7,6 @@ ========================================= """ -import tempfile import random from datetime import datetime, timedelta import copy @@ -222,14 +221,7 @@ # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ information_architecture = InformationArchitecture(edges, current_time=start_time) - -information_architecture.plot(tempfile.gettempdir(), save_plot=False) - -# %% -# .. image:: ../../_static/ArchitectureTutorial_InformationArch_img.png -# :width: 500 -# :alt: Image showing information architecture - +information_architecture # %% # Simulate measuring and propagating data over the network @@ -396,12 +388,7 @@ def reduce_tracks(tracks): # The plot below displays the Network Architecture we have built. This includes all Nodes, # including those that do not feature in the Information Architecture (the repeater nodes). -network_architecture.plot(tempfile.gettempdir(), save_plot=False) - -# %% -# .. image:: ../../_static/ArchitectureTutorial_NetworkArch_img.png -# :width: 500 -# :alt: Image showing network architecture +network_architecture # %% # Plot the Network Architecture's Information Architecture @@ -414,13 +401,7 @@ def reduce_tracks(tracks): # representation of multiple edges and nodes that a data piece would be transferred through in # order to pass from a sender to a recipient node. -network_architecture.information_arch.plot(tempfile.gettempdir(), save_plot=False) - -# %% -# .. image:: ../../_static/ArchitectureTutorial_InformationArch_img.png -# :width: 500 -# :alt: Image showing information architecture - +network_architecture.information_arch # %% # Plot Tracks at the Fusion Nodes diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index fbca3cb97..64a73ea81 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -7,7 +7,6 @@ ======================== """ -import tempfile import random import copy import numpy as np @@ -182,18 +181,15 @@ node_B1_tracker.detector = FusionQueue() node_A1 = SensorNode(sensor=bad_sensor, - label='Bad \n SensorNode', - position=(-1, -1)) + label='Bad\nSensorNode') node_B1 = SensorFusionNode(sensor=good_sensor, - label='Good \n SensorFusionNode', + label='Good\nSensorFusionNode', tracker=node_B1_tracker, - fusion_queue=node_B1_tracker.detector, - position=(1, -1)) + fusion_queue=node_B1_tracker.detector) node_C1 = FusionNode(tracker=track_tracker, fusion_queue=fq, latency=0, - label='FusionNode', - position=(0, 0)) + label='Fusion Node') # %% # Edges @@ -213,12 +209,8 @@ # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ NH_architecture = InformationArchitecture(NH_edges, current_time=start_time, use_arrival_time=True) -NH_architecture.plot(tempfile.gettempdir(), use_positions=True, save_plot=False) - -# %% -# .. image:: ../../_static/tutorial3img1.png -# :width: 500 -# :alt: Image showing information architecture +NH_architecture.plot(plot_style='hierarchical') # Style similar to hierarchical +NH_architecture # %% # Run the Simulation @@ -283,18 +275,15 @@ def reduce_tracks(tracks): node_B2_tracker.detector = FusionQueue() node_A2 = SensorNode(sensor=bad_sensor, - label='Bad \n SensorNode', - position=(-1, -1)) + label='Bad\nSensorNode') node_B2 = SensorFusionNode(sensor=good_sensor, - label='Good \n SensorFusionNode', + label='Good\nSensorFusionNode', tracker=node_B2_tracker, - fusion_queue=node_B2_tracker.detector, - position=(1, -1)) + fusion_queue=node_B2_tracker.detector) node_C2 = FusionNode(tracker=track_tracker2, fusion_queue=fq2, latency=0, - label='FusionNode', - position=(0, 0)) + label='Fusion Node') # %% # Create Edges forming a Hierarchical Architecture @@ -308,12 +297,7 @@ def reduce_tracks(tracks): # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ H_architecture = InformationArchitecture(H_edges, current_time=start_time, use_arrival_time=True) -H_architecture.plot(tempfile.gettempdir(), use_positions=True, save_plot=False) - -# %% -# .. image:: ../../_static/tutorial3img2.png -# :width: 500 -# :alt: Image showing information architecture +H_architecture # %% # Run the Simulation diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index deee4c672..cfca4f7cb 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -1,4 +1,5 @@ from abc import abstractmethod +from operator import attrgetter import pydot @@ -9,7 +10,7 @@ from ..types.detection import TrueDetection, Clutter from ._functions import _default_label -from typing import List, Collection, Tuple, Set, Union, Dict +from typing import List, Tuple, Collection, Set, Union, Dict import numpy as np import networkx as nx import graphviz @@ -62,15 +63,9 @@ def __init__(self, *args, **kwargs): last_letters = {'Node': '', 'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', 'RepeaterNode': ''} for node in self.di_graph.nodes: - if node.label: - label = node.label - else: - label, last_letters = _default_label(node, last_letters) - node.label = label - attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", - "fontsize": f"{node.font_size}", "width": f"{node.node_dim[0]}", - "height": f"{node.node_dim[1]}", "fixedsize": True} - self.di_graph.nodes[node].update(attr) + if not node.label: + node.label, last_letters = _default_label(node, last_letters) + self.di_graph.nodes[node].update(self._node_kwargs(node)) def recipients(self, node: Node): """Returns a set of all nodes to which the input node has a direct edge to""" @@ -195,37 +190,43 @@ def repeater_nodes(self): repeater_nodes.add(node) return repeater_nodes - def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, - bgcolour="white", node_style="filled", font_name='helvetica', save_plot=True, - plot_style=None): + @staticmethod + def _node_kwargs(node, use_position=True): + node_kwargs = { + 'label': node.label, + 'shape': node.shape, + 'color': node.colour, + } + if node.font_size: + node_kwargs['fontsize'] = node.font_size + if node.node_dim: + node_kwargs['width'] = node.node_dim[0] + node_kwargs['height'] = node.node_dim[1] + if use_position and node.position: + if not isinstance(node.position, Tuple): + raise TypeError("Node position, must be Sequence of length 2") + node_kwargs["pos"] = f"{node.position[0]},{node.position[1]}!" + return node_kwargs + + def plot(self, use_positions=False, plot_title=False, + bgcolour="transparent", node_style="filled", font_name='helvetica', plot_style=None): """Creates a pdf plot of the directed graph and displays it - :param dir_path: The path to save the pdf and .gv files to - :param filename: Name to call the associated files :param use_positions: :param plot_title: If a string is supplied, makes this the title of the plot. If True, uses the name attribute of the graph to title the plot. If False, no title is used. Default is False :param bgcolour: String containing the background colour for the plot. - Default is "lightgray". See graphviz attributes for more information. + Default is "transparent". See graphviz attributes for more information. One alternative is "white" :param node_style: String containing the node style for the plot. Default is "filled". See graphviz attributes for more information. One alternative is "solid". - :param save_plot: Boolean set to true by default. Setting to False prevents the plot from - being displayed. :param plot_style: String providing a style to be used to plot the graph. Currently only one option for plot style given by plot_style = 'hierarchical'. """ - if use_positions: - for node in self.di_graph.nodes: - if not isinstance(node.position, Tuple): - raise TypeError("If use_positions is set to True, every node must have a " - "position, given as a Tuple of length 2") - attr = {"pos": f"{node.position[0]},{node.position[1]}!"} - self.di_graph.nodes[node].update(attr) - elif self.is_hierarchical or plot_style == 'hierarchical': - + is_hierarchical = self.is_hierarchical or plot_style == 'hierarchical' + if is_hierarchical: # Find top node and assign location top_nodes = self.top_level_nodes if len(top_nodes) == 1: @@ -233,103 +234,60 @@ def plot(self, dir_path, filename=None, use_positions=False, plot_title=False, else: raise ValueError("Graph with more than one top level node provided.") - top_node.position = (0, 0) - attr = {"pos": f"{top_node.position[0]},{top_node.position[1]}!"} - self.di_graph.nodes[top_node].update(attr) - - # Set of nodes that have been plotted / had their positions updated - plotted_nodes = set() - plotted_nodes.add(top_node) - - # Set of nodes that have been plotted, but need to have recipient nodes plotted - layer_nodes = set() - layer_nodes.add(top_node) - # Initialise a layer count - layer = -1 - while len(plotted_nodes) < len(self.all_nodes): - - # Initialise an empty set to store nodes to be considered in the next iteration - next_layer_nodes = set() - - # Iterate through nodes on the current layer (nodes that have been plotted but have - # sender nodes that are not plotted) - for layer_node in layer_nodes: - - # Find senders of the recipient node - senders = self.senders(layer_node) - - # Find number of leaf nodes that are senders to the recipient - n_recipient_leaves = self.number_of_leaves(layer_node) - - # Get recipient x_loc - recipient_x_loc = layer_node.position[0] - - # Get location of left limit of the range that leaf nodes will be plotted in - l_x_loc = recipient_x_loc - n_recipient_leaves/2 - left_limit = l_x_loc - - for sender in senders: - # Calculate x_loc of the sender node - x_loc = left_limit + self.number_of_leaves(sender)/2 - - # Update the left limit - left_limit += self.number_of_leaves(sender) - - # Update the position of the sender node - sender.position = (x_loc, layer) - attr = {"pos": f"{sender.position[0]},{sender.position[1]}!"} - self.di_graph.nodes[sender].update(attr) - - # Add sender node to list of nodes to be considered in next iteration, and - # to list of nodes that have been plotted - next_layer_nodes.add(sender) - plotted_nodes.add(sender) - - # Set list of nodes to be considered next iteration - layer_nodes = next_layer_nodes - - # Update layer count for correct y location - layer -= 1 + node_layers = [[top_node]] + processed_nodes = {top_node} + while self.all_nodes - processed_nodes: + senders = [ + sender + for node in node_layers[-1] + for sender in sorted(self.senders(node), key=attrgetter('label'))] + if not senders: + break + else: + node_layers.append(senders) + processed_nodes.update(senders) strict = nx.number_of_selfloops(self.di_graph) == 0 and not self.di_graph.is_multigraph() - graph = pydot.Dot(graph_name='', strict=strict, graph_type='digraph') - for node in self.all_nodes: - if use_positions or self.is_hierarchical or plot_style == 'hierarchical': - str_position = '"' + str(node.position[0]) + ',' + str(node.position[1]) + '!"' - new_node = pydot.Node('"' + node.label + '"', label=node.label, shape=node.shape, - pos=str_position, color=node.colour, fontsize=node.font_size, - height=str(node.node_dim[1]), width=str(node.node_dim[0]), - fixedsize=True) - else: - new_node = pydot.Node('"' + node.label + '"', label=node.label, shape=node.shape, - color=node.colour, fontsize=node.font_size, - height=str(node.node_dim[1]), width=str(node.node_dim[0]), - fixedsize=True) - graph.add_node(new_node) + graph = pydot.Dot(graph_name='', strict=strict, graph_type='digraph', rankdir='BT') + if isinstance(plot_title, str): + graph.set_graph_defaults(label=plot_title, labelloc='t') + elif isinstance(plot_title, bool) and plot_title: + graph.set_graph_defaults(label=self.name, labelloc='t') + elif not isinstance(plot_title, bool): + raise ValueError("Plot title must be a string or bool") + graph.set_graph_defaults(bgcolor=bgcolour) + graph.set_node_defaults(fontname=font_name, style=node_style) + + if is_hierarchical: + for n, layer_nodes in enumerate(node_layers): + subgraph = pydot.Subgraph(rank='max' if n == 0 else 'same') + for node in layer_nodes: + new_node = pydot.Node( + node.label.replace("\n", " "), **self._node_kwargs(node, use_positions)) + subgraph.add_node(new_node) + graph.add_subgraph(subgraph) + else: + graph.set_overlap('false') + for node in self.all_nodes: + new_node = pydot.Node( + node.label.replace("\n", " "), **self._node_kwargs(node, use_positions)) + graph.add_node(new_node) for edge in self.edges.edge_list: - new_edge = pydot.Edge('"' + edge[0].label + '"', '"' + edge[1].label + '"') + new_edge = pydot.Edge( + edge[0].label.replace("\n", " "), edge[1].label.replace("\n", " ")) graph.add_edge(new_edge) - dot = graph.to_string() - dot_split = dot.split('{', maxsplit=1) - dot_split.insert(1, '\n' + f"graph [bgcolor={bgcolour}]") - dot_split.insert(1, '\n' + f"node [fontname={font_name}]") - dot_split.insert(1, '{ \n' + f"node [style={node_style}]") - dot = ''.join(dot_split) - - if plot_title: - if plot_title is True: - plot_title = self.name - elif not isinstance(plot_title, str): - raise ValueError("Plot title must be a string, or True") - dot = dot[:-2] + "labelloc=\"t\";\n" + f"label=\"{plot_title}\";" + "}" - if not filename: - filename = self.name - viz_graph = graphviz.Source(dot, filename=filename, directory=dir_path, engine='neato') - if save_plot: - viz_graph.view() + viz_graph = graphviz.Source( + graph.to_string(), engine='dot' if is_hierarchical else 'neato') + self._viz_graph = viz_graph + return viz_graph + + def _repr_html_(self): + if getattr(self, '_viz_graph', None) is None: + self.plot() + return self._viz_graph._repr_image_svg_xml() @property def density(self): @@ -502,15 +460,9 @@ def __init__(self, *args, **kwargs): last_letters = {'Node': '', 'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', 'RepeaterNode': ''} for node in self.di_graph.nodes: - if node.label: - label = node.label - else: - label, last_letters = _default_label(node, last_letters) - node.label = label - attr = {"label": f"{label}", "color": f"{node.colour}", "shape": f"{node.shape}", - "fontsize": f"{node.font_size}", "width": f"{node.node_dim[0]}", - "height": f"{node.node_dim[1]}", "fixedsize": True} - self.di_graph.nodes[node].update(attr) + if not node.label: + node.label, last_letters = _default_label(node, last_letters) + self.di_graph.nodes[node].update(self._node_kwargs(node)) def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index faeb7e369..069d2efc7 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -34,11 +34,12 @@ class Node(Base): default='rectangle', doc='Shape used to display nodes. Default is a rectangle') font_size: int = Property( - default=5, - doc='Font size for node labels. Default is 5') + default=None, + doc='Font size for node labels. Default is None') node_dim: tuple = Property( - default=(0.5, 0.5), - doc='Width and height of nodes for graph icons. Default is (0.5, 0.5)') + default=None, + doc='Width and height of nodes for graph icons. ' + 'Default is None, which will size to label automatically.') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -91,9 +92,6 @@ class SensorNode(Node): shape: str = Property( default='oval', doc='Shape used to display nodes. Default is an oval') - node_dim: tuple = Property( - default=(0.6, 0.3), - doc='Width and height of nodes for graph icons. Default is (0.6, 0.3)') class FusionNode(Node): @@ -112,9 +110,6 @@ class FusionNode(Node): shape: str = Property( default='hexagon', doc='Shape used to display nodes. Default is a hexagon') - node_dim: tuple = Property( - default=(0.8, 0.4), - doc='Width and height of nodes for graph icons. Default is (0.8, 0.4)') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -168,9 +163,6 @@ class SensorFusionNode(SensorNode, FusionNode): shape: str = Property( default='diamond', doc='Shape used to display nodes. Default is a diamond') - node_dim: tuple = Property( - default=(0.9, 0.5), - doc='Width and height of nodes for graph icons. Default is (0.9, 0.5)') class RepeaterNode(Node): @@ -183,9 +175,6 @@ class RepeaterNode(Node): shape: str = Property( default='rectangle', doc='Shape used to display nodes. Default is a rectangle') - node_dim: tuple = Property( - default=(0.7, 0.4), - doc='Width and height of nodes for graph icons. Default is (0.7, 0.4)') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index f452c7235..0735dbfdb 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -10,70 +10,49 @@ from stonesoup.types.detection import TrueDetection -def test_hierarchical_plot(tmpdir, nodes, edge_lists): +def test_hierarchical_plot(nodes, edge_lists): edges = edge_lists["hierarchical_edges"] sf_radar_edges = edge_lists["sf_radar_edges"] arch = InformationArchitecture(edges=edges) - arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False) - - # Check that nodes are plotted on the correct layer. x position of each node is can change - # depending on the order that they are iterated though, hence not entirely predictable so no - # assertion on this is made. - assert nodes['s1'].position[1] == 0 - assert nodes['s2'].position[1] == -1 - assert nodes['s3'].position[1] == -1 - assert nodes['s4'].position[1] == -2 - assert nodes['s5'].position[1] == -2 - assert nodes['s6'].position[1] == -2 - assert nodes['s7'].position[1] == -3 - - # Check that type(position) for each node is a tuple. - assert type(nodes['s1'].position) == tuple - assert type(nodes['s2'].position) == tuple - assert type(nodes['s3'].position) == tuple - assert type(nodes['s4'].position) == tuple - assert type(nodes['s5'].position) == tuple - assert type(nodes['s6'].position) == tuple - assert type(nodes['s7'].position) == tuple + arch.plot() decentralised_edges = edge_lists["decentralised_edges"] arch = InformationArchitecture(edges=decentralised_edges) with pytest.raises(ValueError): - arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_style='hierarchical') + arch.plot(plot_style='hierarchical') arch2 = InformationArchitecture(edges=sf_radar_edges) - arch2.plot(dir_path=tmpdir.join('test2.pdf'), save_plot=False) + arch2.plot() -def test_plot_title(nodes, tmpdir, edge_lists): +def test_plot_title(nodes, edge_lists): edges = edge_lists["decentralised_edges"] arch = InformationArchitecture(edges=edges) # Check that plot function runs when plot_title is given as a str. - arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_title="This is the title of " - "my plot") + arch.plot(plot_title="This is the title of my plot") # Check that plot function runs when plot_title is True. - arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_title=True) + arch.plot(plot_title=True) # Check that error is raised when plot_title is not a str or a bool. x = RepeaterNode() with pytest.raises(ValueError): - arch.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, plot_title=x) + arch.plot(plot_title=x) -def test_plot_positions(nodes, tmpdir): +def test_plot_positions(nodes): edges1 = Edges([Edge((nodes['p2'], nodes['p1'])), Edge((nodes['p3'], nodes['p1']))]) arch1 = InformationArchitecture(edges=edges1) - arch1.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, use_positions=True) + arch1.plot(use_positions=True) # Assert positions are correct after plot() has run assert nodes['p1'].position == (0, 0) @@ -81,15 +60,14 @@ def test_plot_positions(nodes, tmpdir): assert nodes['p3'].position == (1, -1) # Change plot positions to non tuple values - nodes['p3'].position = RepeaterNode() + nodes['p1'].position = RepeaterNode() nodes['p2'].position = 'Not a tuple' nodes['p3'].position = ['Definitely', 'not', 'a', 'tuple'] - edges2 = Edges([Edge((nodes['s2'], nodes['s1'])), Edge((nodes['s3'], nodes['s1']))]) - arch2 = InformationArchitecture(edges=edges2) + edges2 = Edges([Edge((nodes['p2'], nodes['p1'])), Edge((nodes['p3'], nodes['p1']))]) with pytest.raises(TypeError): - arch2.plot(dir_path=tmpdir.join('test.pdf'), save_plot=False, use_positions=True) + InformationArchitecture(edges=edges2) def test_density(edge_lists): diff --git a/stonesoup/architecture/tests/test_node.py b/stonesoup/architecture/tests/test_node.py index 0c22bea71..e6d64adf8 100644 --- a/stonesoup/architecture/tests/test_node.py +++ b/stonesoup/architecture/tests/test_node.py @@ -12,7 +12,7 @@ def test_node(data_pieces, times, nodes): node = Node() assert node.latency == 0.0 - assert node.font_size == 5 + assert node.font_size is None assert len(node.data_held) == 3 assert node.data_held == {"fused": {}, "created": {}, "unfused": {}} @@ -41,7 +41,6 @@ def test_sensor_node(nodes): assert snode.sensor == sensor assert snode.colour == '#006eff' assert snode.shape == 'oval' - assert snode.node_dim == (0.6, 0.3) def test_fusion_node(tracker): @@ -51,7 +50,6 @@ def test_fusion_node(tracker): assert fnode.colour == '#00b53d' assert fnode.shape == 'hexagon' - assert fnode.node_dim == (0.8, 0.4) assert fnode.tracks == set() with pytest.raises(TypeError): @@ -70,7 +68,6 @@ def test_sf_node(tracker, nodes): assert sfnode.colour == '#fc9000' assert sfnode.shape == 'diamond' - assert sfnode.node_dim == (0.9, 0.5) assert sfnode.tracks == set() @@ -80,4 +77,3 @@ def test_repeater_node(): assert rnode.colour == '#909090' assert rnode.shape == 'rectangle' - assert rnode.node_dim == (0.7, 0.4) From 09491da9d8fe0104dde02d25149bfcefc4214507 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Mon, 22 Jan 2024 15:27:11 +0000 Subject: [PATCH 105/170] Install graphviz on CircleCI to render architecture diagrams --- .circleci/config.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 873f74ed4..9d2d5a999 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,6 +65,11 @@ jobs: - checkout - restore_cache: key: dependencies-doc-{{ .Environment.CACHE_VERSION }}-{{ checksum "/home/circleci/.pyenv/version" }}-{{ checksum "setup.cfg" }} + - run: + name: Install OS Dependencies + command: | + sudo apt-get update + sudo apt-get install -y graphviz - run: name: Install Dependencies command: | From de6a516fa38c252b5f5901aa6c5c4ea1c1ada49f Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Fri, 26 Jan 2024 10:43:32 +0000 Subject: [PATCH 106/170] Use node labels when plotting in info./net. architecture example --- .../02_Information_and_Network_Architectures.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index b8dc5b644..8f41088c9 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -249,11 +249,11 @@ def reduce_tracks(tracks): plotter = Plotterly() plotter.plot_ground_truths(truths, [0, 2]) -plotter.plot_tracks(reduce_tracks(node_C.tracks), [0, 2], track_label="Node C", +plotter.plot_tracks(reduce_tracks(node_C.tracks), [0, 2], track_label=node_C.label, line=dict(color='#00FF00'), uncertainty=True) -plotter.plot_tracks(reduce_tracks(node_F.tracks), [0, 2], track_label="Node F", +plotter.plot_tracks(reduce_tracks(node_F.tracks), [0, 2], track_label=node_F.label, line=dict(color='#FF0000'), uncertainty=True) -plotter.plot_tracks(reduce_tracks(node_G.tracks), [0, 2], track_label="Node G", +plotter.plot_tracks(reduce_tracks(node_G.tracks), [0, 2], track_label=node_G.label, line=dict(color='#0000FF'), uncertainty=True) plotter.plot_sensors(sensor_set) plotter.fig @@ -409,11 +409,11 @@ def reduce_tracks(tracks): plotter = Plotterly() plotter.plot_ground_truths(truths, [0, 2]) -plotter.plot_tracks(reduce_tracks(node_C.tracks), [0, 2], track_label="Node C", +plotter.plot_tracks(reduce_tracks(node_C.tracks), [0, 2], track_label=node_C.label, line=dict(color='#00FF00'), uncertainty=True) -plotter.plot_tracks(reduce_tracks(node_F.tracks), [0, 2], track_label="Node F", +plotter.plot_tracks(reduce_tracks(node_F.tracks), [0, 2], track_label=node_F.label, line=dict(color='#FF0000'), uncertainty=True) -plotter.plot_tracks(reduce_tracks(node_G.tracks), [0, 2], track_label="Node G", +plotter.plot_tracks(reduce_tracks(node_G.tracks), [0, 2], track_label=node_G.label, line=dict(color='#0000FF'), uncertainty=True) plotter.plot_sensors(sensor_set) plotter.fig From 6eb99e65598e2c9c9dd190092fe5d4fff86802c3 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Fri, 9 Feb 2024 10:18:59 +0000 Subject: [PATCH 107/170] Fix architecture tutorial thumbnail images and remove old images --- ...ArchitectureTutorial_InformationArch_img.png | Bin 39869 -> 0 bytes .../ArchitectureTutorial_NetworkArch_img.png | Bin 40557 -> 0 bytes .../architecture_sensorfusionexample.png | Bin 48619 -> 0 bytes .../_static/architecture_simpleexample.png | Bin 17330 -> 0 bytes .../_static/sphinx_gallery/ArchTutorial_1.png | Bin 0 -> 5889 bytes .../_static/sphinx_gallery/ArchTutorial_2.png | Bin 0 -> 32227 bytes .../_static/sphinx_gallery/ArchTutorial_3.png | Bin 0 -> 15771 bytes docs/source/_static/tutorial3img1.png | Bin 15877 -> 0 bytes docs/source/_static/tutorial3img2.png | Bin 15548 -> 0 bytes .../01_Introduction_to_Architectures.py | 2 ++ .../02_Information_and_Network_Architectures.py | 2 ++ .../architecture/03_Avoiding_Data_Incest.py | 2 ++ 12 files changed, 6 insertions(+) delete mode 100644 docs/source/_static/ArchitectureTutorial_InformationArch_img.png delete mode 100644 docs/source/_static/ArchitectureTutorial_NetworkArch_img.png delete mode 100644 docs/source/_static/architecture_sensorfusionexample.png delete mode 100644 docs/source/_static/architecture_simpleexample.png create mode 100644 docs/source/_static/sphinx_gallery/ArchTutorial_1.png create mode 100644 docs/source/_static/sphinx_gallery/ArchTutorial_2.png create mode 100644 docs/source/_static/sphinx_gallery/ArchTutorial_3.png delete mode 100644 docs/source/_static/tutorial3img1.png delete mode 100644 docs/source/_static/tutorial3img2.png diff --git a/docs/source/_static/ArchitectureTutorial_InformationArch_img.png b/docs/source/_static/ArchitectureTutorial_InformationArch_img.png deleted file mode 100644 index b25e4059d1bb637e65d9763b86eb5b4483360d8d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39869 zcmcG$g4OllLEfpui6zS51Xyu;T7J%ix!b)=yQR!eE$)E61-cf#1nqD;l`MVAmU=|3od$ zc@{8OvZj*kQ*95E^-0piTRKC{Thu`*H`}-q<)r0qbv|CJvDYu1PI|DI;@bWAA1cpY ze-34@@2aao#?8&F7#%jBa@@U){UXuTDVXV8+|lvNPf)(gUx?s z>quBR&LsaE0zb<=?4SzLo!9)9MIvVRMQVBIi`t9HF}G(tFjy-oD^PT+R@#q8Hh*q= zxwTtoUK(6>cB~wBl_+M9B7#XX&b*}ni@ZS=mb9PVmcA1CRF55d1qO>`f)*5Mt!O$= zu5NKh{O@H7S}-pewBGrfQu>mw|17WyZnyHpNZo_RNrRRxoukV-9uKdD zT!q0ZtERCcD zR%kg+kxeidbFiXm6FD^Gn_$Qc9s>!n&{87l&y`I?L1P*QzAcG>zBO1REh3OJ2N1E5 zykUdJz@Lv1s!0|mniKs)ngSZAEEawZn2FYCVgOY~yYF)}j z66J+l>Zn#2EW_?xX>zQB8YZmwVX!pcOfwkqrg%#h{Xf8DgzRLoI}|Z zv{)x1n19LO(d0)0`0i@{#bzTdLU8;`BmR>|GD!yQ#Wk-k*!Hs<94q}YS+e%7m%D~^ za_^`?;@*|6S*}%!I^G(?Jx}91G26(rcFi}jDTuYi?LR=0s3P|Yc4Y@w1d1w$RFdHZ zM{<3hJzzbe(5hl(_BaG5%$B#&*#XEk)VNzDqoE#(KBvH;`fVrzn{TfyT6Fhz;G%

rQ~1&HO7;~JMFx@RZes~y`3sPq+MAbI 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 diff --git a/docs/source/_static/ArchitectureTutorial_NetworkArch_img.png b/docs/source/_static/ArchitectureTutorial_NetworkArch_img.png deleted file mode 100644 index 3f8837f94532c3ac94cbf99a1e6ebf78a0f2612f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40557 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

kO%U#3jeIcH^_Zv}}yD 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Ⓢdsj+-kUN1H8 zJi}@x)+dsFcPjP|%I;cNYw75`M(64b8xghsGNwk2!+W-arK_iLrCye)tNxGC zk4bxMgj@WHY8x^?sEJVx4a(<$�TPa_+^BMndZ)B<*no5_+ND8<3bT?f|FR=K(pU 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 diff --git a/docs/source/_static/architecture_simpleexample.png b/docs/source/_static/architecture_simpleexample.png deleted file mode 100644 index 681573639c8a5baf7248970e709ef9a4f6b63f45..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17330 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 literal 0 HcmV?d00001 diff --git a/docs/source/_static/sphinx_gallery/ArchTutorial_2.png b/docs/source/_static/sphinx_gallery/ArchTutorial_2.png new file mode 100644 index 0000000000000000000000000000000000000000..8b5118d1403c9b38dc978601cce7ceacbedf0b61 GIT binary patch literal 32227 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 XIOEIT)Mc=*w&Ofj(^dVfY!mf={cQ&n literal 0 HcmV?d00001 diff --git a/docs/source/_static/sphinx_gallery/ArchTutorial_3.png b/docs/source/_static/sphinx_gallery/ArchTutorial_3.png new file mode 100644 index 0000000000000000000000000000000000000000..f7fbc73f3995d5f2813bb59876578da294a21525 GIT binary patch literal 15771 zcmZv@1yodD_%=F2BSW{8lF}uiq|)6mbR#u{G?D@XNC_$-9fEX73_UOc(%m_fba&qI z_rLf1zPs)n)?&`=^PcDI-p_vDXGd$QE8stQ`UC&~;43N0Y6Adh)~M?d94yp-6OH?nsFnIKSzW( zD7xCfS`|Yje)+i)I|y_1G5eD5;xa=jxMcZxbktXc-c&`0Lc*;`p*1)GW@ipFS6p}~ z(GjrwA>Q)T9AR-!pe)Nwj6&-g+*)QBjzzapN-bm_H^5LKLI}jRCfww{6aC;abC@G5 zyUI>Y3jD(YceszV9&`;Qt(N7(xF=9a>P81ay}+mMo?_4=_ZqS9P>2{`W0mS}kJf4H zC)%G5xQ4iZ*FyD3l8ZqU!$y2!aLJHGsWjr4i)*^%C>s$L>!i#=!cx2s6~EVfEC_lm zMs}a8fER-6qC*zZ?HH%h|6rc_T+yXP*{}s(DSG?;Ft>i&!4O7=T?;_l^7_2rDGf~R z!BIH)n}i1^16M_TLjI$<~A= z@%K894>?92cUw@Cn&K5QtM*yqRk4O34a=_4DF=)F=v^DhrJ;Irmii0b&M zS*rU62ql^t`2DL9)f$2A=p=~)1vaVzF(n%+HN_?EQ4@Tq0#WO7n>%Ke+{7rxX$r7z zt4*H#xcGOI`CAgdl2l1DZ}A(wdpTxy6#C6ksnG-U-t9i}hxt+?P(0xM;KHTB2g`w; zqeXQ$wZGpR6Fx-+6AU@yx9=IAq*As)*$fc-5|$Rb>r25)c2tincRTi5%N&gjrluu3 zyo{4%M`D1)_i7CKDckqK@WPw^;{j4|Q=`A8qw#u-pRwg+Ly$t^Yc4EnG%6!2w05=; z3yE>8%@d=?&7eqD#_cz|PRw3iB^&dtYw%k5l?nG_xZTmMS>1

uhl=#H0?{bTj? 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 DIpb>a literal 0 HcmV?d00001 diff --git a/docs/source/_static/tutorial3img1.png b/docs/source/_static/tutorial3img1.png deleted file mode 100644 index 348671c3f4d0e1eccb2c3e912de8b82ed128d530..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15877 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%0A<3h)_C0VandWbaB4pzf6+-dWgrW?XVg{rY69 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 MrmU@0qhKBOKMqpYYybcN diff --git a/docs/tutorials/architecture/01_Introduction_to_Architectures.py b/docs/tutorials/architecture/01_Introduction_to_Architectures.py index 415416436..9d8aaddc9 100644 --- a/docs/tutorials/architecture/01_Introduction_to_Architectures.py +++ b/docs/tutorials/architecture/01_Introduction_to_Architectures.py @@ -109,6 +109,8 @@ # The `current_time` property of an Architecture instance maintains the current time within the # simulation. By default, this begins at the current time of the operating system. +# sphinx_gallery_thumbnail_path = '_static/sphinx_gallery/ArchTutorial_1.png' + from stonesoup.architecture import InformationArchitecture arch = InformationArchitecture(edges=edges) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 8f41088c9..0b9b63843 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -220,6 +220,8 @@ # Build and plot Information Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# sphinx_gallery_thumbnail_path = '_static/sphinx_gallery/ArchTutorial_2.png' + information_architecture = InformationArchitecture(edges, current_time=start_time) information_architecture diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 64a73ea81..ee8c08048 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -208,6 +208,8 @@ # Create the Non-Hierarchical Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# sphinx_gallery_thumbnail_path = '_static/sphinx_gallery/ArchTutorial_3.png' + NH_architecture = InformationArchitecture(NH_edges, current_time=start_time, use_arrival_time=True) NH_architecture.plot(plot_style='hierarchical') # Style similar to hierarchical NH_architecture From 277ba18b9cebb1470d04c8d6efd2feda644fba89 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Fri, 9 Feb 2024 10:47:25 +0000 Subject: [PATCH 108/170] Fix architecture tutorial doc build warnings --- ...2_Information_and_Network_Architectures.py | 22 ++++++++-------- .../architecture/03_Avoiding_Data_Incest.py | 26 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 0b9b63843..4bea534a1 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -351,18 +351,18 @@ def reduce_tracks(tracks): # :class:`~.NetworkArchitecture` # # - Firstly, providing the :class:`~.NetworkArchitecture` with an `edge_list` for the network -# architecture (an Edges object), and a pre-fabricated InformationArchitecture object, which must -# be provided as property `information_arch`. +# architecture (an Edges object), and a pre-fabricated InformationArchitecture object, which must +# be provided as property `information_arch`. # -# - Secondly, by providing the NetworkArchitecture with two `edge_list`s: one for the network -# architecture and one for the information architecture. +# - Secondly, by providing the NetworkArchitecture with two `edge_list` values: one for the network +# architecture and one for the information architecture. # # - Thirdly, by providing just a set of edges for the network architecture. In this case, -# the NetworkArchitecture class will infer which nodes in the network architecture are also -# part of the information architecture, and form an edge set by calculating the fastest routes -# (the lowest latency) between each set of nodes in the architecture. Warning: this method is for -# ease of use, and may not represent the information architecture you are designing - it is -# best to check by plotting the generated information architecture. +# the NetworkArchitecture class will infer which nodes in the network architecture are also +# part of the information architecture, and form an edge set by calculating the fastest routes +# (the lowest latency) between each set of nodes in the architecture. Warning: this method is for +# ease of use, and may not represent the information architecture you are designing - it is +# best to check by plotting the generated information architecture. # @@ -406,8 +406,8 @@ def reduce_tracks(tracks): network_architecture.information_arch # %% -# Plot Tracks at the Fusion Nodes -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Plot Tracks at the Fusion Nodes (Network Architecture) +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ plotter = Plotterly() plotter.plot_ground_truths(truths, [0, 2]) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index ee8c08048..5a32ba713 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -215,16 +215,16 @@ NH_architecture # %% -# Run the Simulation -# ^^^^^^^^^^^^^^^^^^ +# Run the Non-Hierarchical Simulation +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ for time in timesteps: NH_architecture.measure(truths, noise=True) NH_architecture.propagate(time_increment=1) # %% -# Extract all detections that arrived at Node C -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Extract all detections that arrived at Non-Hierarchical Node C +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ from stonesoup.types.detection import TrueDetection @@ -235,8 +235,8 @@ NH_detections.add(datapiece.data) # %% -# Plot the tracks stored at Node C -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Plot the tracks stored at Non-Hierarchical Node C +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ from stonesoup.plotter import Plotterly @@ -295,23 +295,23 @@ def reduce_tracks(tracks): Edge((node_B2, node_C2), edge_latency=0)]) # %% -# Create the Non-Hierarchical Architecture +# Create the Hierarchical Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ H_architecture = InformationArchitecture(H_edges, current_time=start_time, use_arrival_time=True) H_architecture # %% -# Run the Simulation -# ^^^^^^^^^^^^^^^^^^ +# Run the Hierarchical Simulation +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ for time in timesteps: H_architecture.measure(truths, noise=True) H_architecture.propagate(time_increment=1) # %% -# Extract all detections that arrived at Node C -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Extract all detections that arrived at Hierarchical Node C +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ H_detections = set() for timestep in node_C2.data_held['unfused']: @@ -320,8 +320,8 @@ def reduce_tracks(tracks): H_detections.add(datapiece.data) # %% -# Plot the tracks stored at Node C -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Plot the tracks stored at Hierarchical Node C +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ plotter = Plotterly() plotter.plot_ground_truths(truths, [0, 2]) From 4f380acb8a49d63c271b37d354bd0df7b68143f1 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 22 Feb 2024 15:59:38 +0000 Subject: [PATCH 109/170] Add InformationArchitectureGenerator and NetworkArchitectureGenerator classes. --- stonesoup/architecture/generator.py | 210 ++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 stonesoup/architecture/generator.py diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py new file mode 100644 index 000000000..b52cfab3a --- /dev/null +++ b/stonesoup/architecture/generator.py @@ -0,0 +1,210 @@ +import operator +import random +import warnings + +import networkx as nx +from datetime import datetime + +import numpy as np + +from stonesoup.architecture import SensorNode, FusionNode, Edge, Edges, InformationArchitecture, \ + NetworkArchitecture +from stonesoup.architecture.node import SensorFusionNode, RepeaterNode +from stonesoup.base import Base, Property + + +class InformationArchitectureGenerator(Base): + """ + Class that can be used to generate InformationArchitecture classes given a set of input + parameters. + """ + arch_type: str = Property( + doc="Type of architecture to be modelled. Currently only 'hierarchical' and " + "'decentralised' are supported.", + default='decentralised') + start_time: datetime = Property( + doc="Start time of simulation to be passed to the Architecture class.", + default=datetime.now()) + node_ratio: tuple = Property( + doc="Tuple containing the number of each type of node, in the order of (sensor nodes, " + "sensor fusion nodes, fusion nodes).", + default=None) + mean_degree: float = Property( + doc="Average (mean) degree of nodes in the network.", + default=None) + sensors: list = Property( + doc="A list of sensors that are used to create SensorNodes.", + default=None) + trackers: list = Property( + doc="A list of trackers that are used to create FusionNodes.", + default=None) + iteration_limit: int = Property( + doc="Limit for the number of iterations the generate_edgelist() method can make when " + "attempting to build a suitable graph.", + default=10000) + allow_invalid_graph: bool = Property( + doc="Bool where True allows invalid graphs to be returned without throwing an error. " + "False by default", + default=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.n_nodes = sum(self.node_ratio) + self.n_sensor_nodes = self.node_ratio[0] + self.n_fusion_nodes = self.node_ratio[2] + self.n_sensor_fusion_nodes = self.node_ratio[1] + + self.n_edges = np.ceil(self.n_nodes * self.mean_degree * 0.5) + + if self.arch_type not in ['decentralised', 'hierarchical']: + raise ValueError('arch_style must be "decentralised" or "hierarchical"') + + def generate(self): + edgelist, degrees = self.generate_edgelist() + + nodes = self.assign_nodes(degrees) + + arch = self.generate_architecture(nodes, edgelist) + + return arch + + def generate_architecture(self, nodes, edgelist): + edges = [] + for t in edgelist: + edge = Edge((nodes[t[0]], nodes[t[1]])) + edges.append(edge) + + arch_edges = Edges(edges) + + arch = InformationArchitecture(arch_edges, self.start_time) + return arch + + def assign_nodes(self, degrees): + reordered = [] + for node_no in degrees.keys(): + reordered.append((node_no, degrees[node_no]['degree'])) + + reordered.sort(key=operator.itemgetter(1)) + order = [] + for t in reordered: + order.append(t[0]) + + components = ['s'] * self.n_sensor_nodes + \ + ['sf'] * self.n_sensor_fusion_nodes + \ + ['f'] * self.n_fusion_nodes + + nodes = {} + n_sensors = 0 + n_trackers = 0 + for node_no, type in zip(order, components): + if type == 's': + node = SensorNode(sensor=self.sensors[n_sensors], label=str(node_no)) + n_sensors += 1 + nodes[node_no] = node + + if type == 'f': + node = FusionNode(tracker=self.trackers[n_trackers], + fusion_queue=self.trackers[n_trackers].detector.reader) + n_trackers += 1 + nodes[node_no] = node + + if type == 'sf': + node = SensorFusionNode(sensor=self.sensors[n_sensors], label=str(node_no), + tracker=self.trackers[n_trackers], + fusion_queue=self.trackers[n_trackers].detector.reader) + n_sensors += 1 + n_trackers += 1 + nodes[node_no] = node + + return nodes + + def generate_edgelist(self): + count = 0 + edges = [] + sources = [] + targets = [] + if self.arch_type == 'hierarchical': + for i in range(1, self.n_nodes): + target = random.randint(0, i - 1) + source = i + edge = (source, target) + edges.append(edge) + sources.append(source) + targets.append(target) + + if self.arch_type == 'decentralised': + network_found = False + while network_found is False and count < self.iteration_limit: + i = 0 + nodes_used = {0} + edges = [] + sources = [] + targets = [] + while i < self.n_edges: + source, target = -1, -1 + while source == target or (source, target) in edges or ( + target, source) in edges: + source = random.randint(0, self.n_nodes-1) + target = random.choice(list(nodes_used)) + edge = (source, target) + edges.append(edge) + nodes_used |= {source, target} + sources.append(source) + targets.append(target) + i += 1 + + if len(nodes_used) == self.n_nodes: + network_found = True + count += 1 + + if not network_found: + if self.allow_invalid_graph: + warnings.warn("Unable to find valid graph within iteration limit. Returned " + "network does not meet requirements") + else: + raise ValueError("Unable to find valid graph within iteration limit. Returned " + "network does not meet requirements") + + degrees = {} + for node in range(self.n_nodes): + degrees[node] = {'source': sources.count(node), 'target': targets.count(node), + 'degree': sources.count(node) + targets.count(node)} + + return edges, degrees + + @staticmethod + def plot_edges(edges): + G = nx.DiGraph() + G.add_edges_from(edges) + nx.draw(G) + + +class NetworkArchitectureGenerator(InformationArchitectureGenerator): + """ + Class that can be used to generate NetworkArchitecture classes given a set of input + parameters. + """ + n_routes: tuple = Property( + doc="Tuple containing a minimum and maximum value for the number of routes created in the " + "network architecture to represent a single edge in the information architecture.", + default=(1, 2)) + + def generate_architecture(self, nodes, edgelist): + edges = [] + + for e in edgelist: + # Choose number of routes between two information architecture nodes + n = self.n_routes if len(self.n_routes) == 1 else \ + random.randint(self.n_routes[0], self.n_routes[1]) + + for route in range(n): + r = RepeaterNode() + edge1 = Edge((nodes[e[0]], r)) + edge2 = Edge((r, nodes[e[1]])) + edges.append(edge1) + edges.append(edge2) + + arch_edges = Edges(edges) + arch = NetworkArchitecture(edges=arch_edges, current_time=self.start_time) + + return arch From 127aed22dd914d61f13955b101861aad04fbd51d Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Mon, 26 Feb 2024 11:35:21 +0000 Subject: [PATCH 110/170] Simplify node label generation and avoid duplicate node names --- stonesoup/architecture/__init__.py | 19 ++++++----- stonesoup/architecture/_functions.py | 33 ++----------------- .../architecture/tests/test_functions.py | 27 +++++---------- 3 files changed, 22 insertions(+), 57 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index cfca4f7cb..75121ae9b 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -8,7 +8,7 @@ from .edge import Edges, DataPiece, Edge from ..types.groundtruth import GroundTruthPath from ..types.detection import TrueDetection, Clutter -from ._functions import _default_label +from ._functions import _default_label_gen from typing import List, Tuple, Collection, Set, Union, Dict import numpy as np @@ -59,12 +59,13 @@ def __init__(self, *args, **kwargs): raise ValueError("The graph is not connected. Use force_connected=False, " "if you wish to override this requirement") - # Set attributes such as label, colour, shape, etc. for each node - last_letters = {'Node': '', 'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', - 'RepeaterNode': ''} + node_label_gens = {} + labels = {node.label.replace("\n", " ") for node in self.di_graph.nodes if node.label} for node in self.di_graph.nodes: if not node.label: - node.label, last_letters = _default_label(node, last_letters) + label_gen = node_label_gens.setdefault(type(node), _default_label_gen(type(node))) + while not node.label or node.label.replace("\n", " ") in labels: + node.label = next(label_gen) self.di_graph.nodes[node].update(self._node_kwargs(node)) def recipients(self, node: Node): @@ -457,11 +458,13 @@ def __init__(self, *args, **kwargs): # Need to reset digraph for info-arch self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) # Set attributes such as label, colour, shape, etc. for each node - last_letters = {'Node': '', 'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', - 'RepeaterNode': ''} + node_label_gens = {} + labels = {node.label.replace("\n", " ") for node in self.di_graph.nodes if node.label} for node in self.di_graph.nodes: if not node.label: - node.label, last_letters = _default_label(node, last_letters) + label_gen = node_label_gens.setdefault(type(node), _default_label_gen(type(node))) + while not node.label or node.label.replace("\n", " ") in labels: + node.label = next(label_gen) self.di_graph.nodes[node].update(self._node_kwargs(node)) def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, diff --git a/stonesoup/architecture/_functions.py b/stonesoup/architecture/_functions.py index 08ffad62d..3e63b56c9 100644 --- a/stonesoup/architecture/_functions.py +++ b/stonesoup/architecture/_functions.py @@ -1,3 +1,4 @@ +from itertools import count, product from string import ascii_uppercase as auc @@ -29,33 +30,5 @@ def _dict_set(my_dict, value, key1, key2=None): return True, my_dict -def _default_label(node, last_letters): - """Utility function to generate default labels for nodes, where none are given - Takes a node, and a dictionary with the letters last used for each class, - ie `last_letters['Node']` might return 'AA', meaning the last Node was labelled 'Node AA'""" - node_type = type(node).__name__ - type_letters = last_letters[node_type] # eg 'A', or 'AA', or 'ABZ' - new_letters = _default_letters(type_letters) - last_letters[node_type] = new_letters - return node_type + ' ' + '\n' + new_letters, last_letters - - -def _default_letters(type_letters) -> str: - """Utility function to work out the letters which go in the default label as part of the - :meth:`~._default_label` - method""" - if type_letters == '': - return 'A' - count = 0 - letters_list = [*type_letters] - # Move through string from right to left and shift any Z's up to A's - while letters_list[-1 - count] == 'Z': - letters_list[-1 - count] = 'A' - count += 1 - if count == len(letters_list): - return 'A' * (count + 1) - # Shift current letter up by one - current_letter = letters_list[-1 - count] - letters_list[-1 - count] = auc[auc.index(current_letter) + 1] - new_letters = ''.join(letters_list) - return new_letters +def _default_label_gen(type_): + return (f"{type_.__name__}\n{''.join(c)}" for n in count(1) for c in product(auc, repeat=n)) diff --git a/stonesoup/architecture/tests/test_functions.py b/stonesoup/architecture/tests/test_functions.py index 9659716dc..d6206b2b7 100644 --- a/stonesoup/architecture/tests/test_functions.py +++ b/stonesoup/architecture/tests/test_functions.py @@ -1,5 +1,5 @@ -from .._functions import _dict_set, _default_label, _default_letters +from .._functions import _dict_set, _default_label_gen from ..node import RepeaterNode @@ -39,24 +39,13 @@ def test_dict_set(): def test_default_label(nodes): - last_letters = {'Node': '', 'SensorNode': '', 'FusionNode': '', 'SensorFusionNode': '', - 'RepeaterNode': 'Z'} node = nodes['a'] - label, last_letters = _default_label(node, last_letters) - assert last_letters['Node'] == 'A' - assert label == 'Node \nA' + label = next(_default_label_gen(type(node))) + assert label == 'Node\nA' repeater = RepeaterNode() - assert last_letters['RepeaterNode'] == 'Z' - label, last_letters = _default_label(repeater, last_letters) - assert last_letters['RepeaterNode'] == 'AA' - assert label == 'RepeaterNode \nAA' - - -def test_default_letters(): - assert _default_letters('') == 'A' - assert _default_letters('A') == 'B' - assert _default_letters('Z') == 'AA' - assert _default_letters('AA') == 'AB' - assert _default_letters('AZ') == 'BA' - assert _default_letters('ZZ') == 'AAA' + gen = _default_label_gen(type(repeater)) + label = [next(gen) for i in range(26)][-1] # A-Z 26 chars + assert label.split("\n")[-1] == 'Z' + label = next(gen) + assert label == 'RepeaterNode\nAA' From 3a200fdd01ea3e7b8eeaa425bf160946bbeb1616 Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 28 Feb 2024 21:27:14 +0000 Subject: [PATCH 111/170] Fixes to generator to produce more realistic decentralised graphs --- stonesoup/architecture/generator.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index b52cfab3a..b1b3bcb14 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -1,3 +1,4 @@ +import copy import operator import random import warnings @@ -62,7 +63,7 @@ def __init__(self, *args, **kwargs): def generate(self): edgelist, degrees = self.generate_edgelist() - nodes = self.assign_nodes(degrees) + nodes, edgelist = self.assign_nodes(degrees, edgelist) arch = self.generate_architecture(nodes, edgelist) @@ -79,10 +80,10 @@ def generate_architecture(self, nodes, edgelist): arch = InformationArchitecture(arch_edges, self.start_time) return arch - def assign_nodes(self, degrees): + def assign_nodes(self, degrees, edgelist): reordered = [] for node_no in degrees.keys(): - reordered.append((node_no, degrees[node_no]['degree'])) + reordered.append((node_no, degrees[node_no]['target'])) reordered.sort(key=operator.itemgetter(1)) order = [] @@ -96,19 +97,20 @@ def assign_nodes(self, degrees): nodes = {} n_sensors = 0 n_trackers = 0 - for node_no, type in zip(order, components): - if type == 's': + for node_no, node_type in zip(order, components): + if node_type == 's': node = SensorNode(sensor=self.sensors[n_sensors], label=str(node_no)) n_sensors += 1 nodes[node_no] = node - if type == 'f': + elif node_type == 'f': node = FusionNode(tracker=self.trackers[n_trackers], - fusion_queue=self.trackers[n_trackers].detector.reader) + fusion_queue=self.trackers[n_trackers].detector.reader, + label=str(node_no)) n_trackers += 1 nodes[node_no] = node - if type == 'sf': + elif node_type == 'sf': node = SensorFusionNode(sensor=self.sensors[n_sensors], label=str(node_no), tracker=self.trackers[n_trackers], fusion_queue=self.trackers[n_trackers].detector.reader) @@ -116,7 +118,16 @@ def assign_nodes(self, degrees): n_trackers += 1 nodes[node_no] = node - return nodes + new_edgelist = copy.copy(edgelist) + for edge in edgelist: + s = edge[0] + t = edge[1] + + if isinstance(nodes[s], FusionNode) and type(nodes[t]) == SensorNode: + new_edgelist.remove((s, t)) + new_edgelist.append((t, s)) + + return nodes, new_edgelist def generate_edgelist(self): count = 0 From af4be326af26425c8959b935a0c00e0586b62b33 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 19 Mar 2024 14:37:39 +0000 Subject: [PATCH 112/170] Progress on MultiArchitectureGenerators --- stonesoup/architecture/generator.py | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index b1b3bcb14..3c0c2fb44 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -219,3 +219,73 @@ def generate_architecture(self, nodes, edgelist): arch = NetworkArchitecture(edges=arch_edges, current_time=self.start_time) return arch + + +class MultiInformationArchitectureGenerator(InformationArchitectureGenerator): + n_archs: int = Property( + doc="Tuple containing a minimum and maximum value for the number of routes created in the " + "network architecture to represent a single edge in the information architecture.", + default=2) + + def generate(self): + edgelist, degrees = self.generate_edgelist() + + nodes, edgelist = self.assign_nodes(degrees, edgelist) + + arch = self.generate_architecture(nodes, edgelist) + + return arch + + def assign_nodes(self, degrees, edgelist): + reordered = [] + for node_no in degrees.keys(): + reordered.append((node_no, degrees[node_no]['target'])) + + reordered.sort(key=operator.itemgetter(1)) + order = [] + for t in reordered: + order.append(t[0]) + + components = ['s'] * self.n_sensor_nodes + \ + ['sf'] * self.n_sensor_fusion_nodes + \ + ['f'] * self.n_fusion_nodes + + nodes = {} + n_sensors = 0 + n_trackers = 0 + for node_no, node_type in zip(order, components): + if node_type == 's': + lab = str(node_no) + sensor = copy.deepcopy(self.sensors[n_sensors]) + + for arch_no in range(self.n_archs): + node = SensorNode(sensor=sensor, label=lab) + #nodes[][node_no] = node + n_sensors += 1 + + + elif node_type == 'f': + node = FusionNode(tracker=self.trackers[n_trackers], + fusion_queue=self.trackers[n_trackers].detector.reader, + label=str(node_no)) + n_trackers += 1 + nodes[node_no] = node + + elif node_type == 'sf': + node = SensorFusionNode(sensor=self.sensors[n_sensors], label=str(node_no), + tracker=self.trackers[n_trackers], + fusion_queue=self.trackers[n_trackers].detector.reader) + n_sensors += 1 + n_trackers += 1 + nodes[node_no] = node + + new_edgelist = copy.copy(edgelist) + for edge in edgelist: + s = edge[0] + t = edge[1] + + if isinstance(nodes[s], FusionNode) and type(nodes[t]) == SensorNode: + new_edgelist.remove((s, t)) + new_edgelist.append((t, s)) + + return nodes, new_edgelist \ No newline at end of file From 5f311879e1640a5a4c528d42c118b88acb3f0648 Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 3 Apr 2024 10:39:16 +0100 Subject: [PATCH 113/170] Functionality added to ArchitectureGenerator classes to allow creation of duplicate architectures --- stonesoup/architecture/generator.py | 201 ++++++++++++++-------------- 1 file changed, 100 insertions(+), 101 deletions(-) diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index 3c0c2fb44..a86a3581a 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -10,8 +10,11 @@ from stonesoup.architecture import SensorNode, FusionNode, Edge, Edges, InformationArchitecture, \ NetworkArchitecture +from stonesoup.architecture.edge import FusionQueue from stonesoup.architecture.node import SensorFusionNode, RepeaterNode from stonesoup.base import Base, Property +from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder +from stonesoup.sensor.sensor import Sensor class InformationArchitectureGenerator(Base): @@ -33,11 +36,18 @@ class InformationArchitectureGenerator(Base): mean_degree: float = Property( doc="Average (mean) degree of nodes in the network.", default=None) - sensors: list = Property( - doc="A list of sensors that are used to create SensorNodes.", + base_sensor: Sensor = Property( + doc="Sensor class object that will be duplicated to create multiple sensors. Position of " + "this sensor is used with 'sensor_max_distance' to calculate a position for " + "duplicated sensors.", default=None) - trackers: list = Property( - doc="A list of trackers that are used to create FusionNodes.", + sensor_max_distance: tuple = Property( + doc="Max distance each sensor can be from base_sensor.position. Should be a tuple of " + "length equal to len(base_sensor.position_mapping)", + default=None) + base_tracker: list = Property( + doc="Tracker class object that will be duplicated to create multiple trackers. Should have " + "detector=None.", default=None) iteration_limit: int = Property( doc="Limit for the number of iterations the generate_edgelist() method can make when " @@ -47,6 +57,10 @@ class InformationArchitectureGenerator(Base): doc="Bool where True allows invalid graphs to be returned without throwing an error. " "False by default", default=False) + n_archs: int = Property( + doc="Tuple containing a minimum and maximum value for the number of routes created in the " + "network architecture to represent a single edge in the information architecture.", + default=2) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -57,6 +71,8 @@ def __init__(self, *args, **kwargs): self.n_edges = np.ceil(self.n_nodes * self.mean_degree * 0.5) + if self.sensor_max_distance is None: + self.sensor_max_distance = tuple(np.zeros(len(self.base_sensor.position_mapping))) if self.arch_type not in ['decentralised', 'hierarchical']: raise ValueError('arch_style must be "decentralised" or "hierarchical"') @@ -65,9 +81,11 @@ def generate(self): nodes, edgelist = self.assign_nodes(degrees, edgelist) - arch = self.generate_architecture(nodes, edgelist) - - return arch + archs = list() + for architecture in nodes.keys(): + arch = self.generate_architecture(nodes[architecture], edgelist) + archs.append(arch) + return archs def generate_architecture(self, nodes, edgelist): edges = [] @@ -95,35 +113,63 @@ def assign_nodes(self, degrees, edgelist): ['f'] * self.n_fusion_nodes nodes = {} - n_sensors = 0 - n_trackers = 0 + for architecture in range(self.n_archs): + nodes[architecture] = {} + for node_no, node_type in zip(order, components): + if node_type == 's': - node = SensorNode(sensor=self.sensors[n_sensors], label=str(node_no)) - n_sensors += 1 - nodes[node_no] = node + pos = np.array( + [[p + random.uniform(-d, d)] for p, d in zip(self.base_sensor.position, + self.sensor_max_distance)]) + for architecture in range(self.n_archs): + s = copy.deepcopy(self.base_sensor) + s.position = pos + + node = SensorNode(sensor=s, label=str(node_no)) + nodes[architecture][node_no] = node elif node_type == 'f': - node = FusionNode(tracker=self.trackers[n_trackers], - fusion_queue=self.trackers[n_trackers].detector.reader, - label=str(node_no)) - n_trackers += 1 - nodes[node_no] = node + for architecture in range(self.n_archs): + t = copy.deepcopy(self.base_tracker) + + fq = FusionQueue() + t.detector = Tracks2GaussianDetectionFeeder(fq) + + node = FusionNode(tracker=t, + fusion_queue=fq, + label=str(node_no)) + + nodes[architecture][node_no] = node elif node_type == 'sf': - node = SensorFusionNode(sensor=self.sensors[n_sensors], label=str(node_no), - tracker=self.trackers[n_trackers], - fusion_queue=self.trackers[n_trackers].detector.reader) - n_sensors += 1 - n_trackers += 1 - nodes[node_no] = node + pos = np.array( + [[p + random.uniform(-d, d)] for p, d in zip(self.base_sensor.position, + self.sensor_max_distance)]) + + for architecture in range(self.n_archs): + s = copy.deepcopy(self.base_sensor) + s.position = pos + + t = copy.deepcopy(self.base_tracker) + + fq = FusionQueue() + t.detector = Tracks2GaussianDetectionFeeder(fq) + + node = SensorFusionNode(sensor=s, + tracker=t, + fusion_queue=fq) + + nodes[architecture][node_no] = node new_edgelist = copy.copy(edgelist) for edge in edgelist: s = edge[0] t = edge[1] - if isinstance(nodes[s], FusionNode) and type(nodes[t]) == SensorNode: + if self.arch_type != 'hierarchical' and \ + isinstance(nodes[0][s], FusionNode) and \ + type(nodes[0][t]) == SensorNode: new_edgelist.remove((s, t)) new_edgelist.append((t, s)) @@ -200,92 +246,45 @@ class NetworkArchitectureGenerator(InformationArchitectureGenerator): "network architecture to represent a single edge in the information architecture.", default=(1, 2)) - def generate_architecture(self, nodes, edgelist): - edges = [] - - for e in edgelist: - # Choose number of routes between two information architecture nodes - n = self.n_routes if len(self.n_routes) == 1 else \ - random.randint(self.n_routes[0], self.n_routes[1]) - - for route in range(n): - r = RepeaterNode() - edge1 = Edge((nodes[e[0]], r)) - edge2 = Edge((r, nodes[e[1]])) - edges.append(edge1) - edges.append(edge2) - - arch_edges = Edges(edges) - arch = NetworkArchitecture(edges=arch_edges, current_time=self.start_time) - - return arch - - -class MultiInformationArchitectureGenerator(InformationArchitectureGenerator): - n_archs: int = Property( - doc="Tuple containing a minimum and maximum value for the number of routes created in the " - "network architecture to represent a single edge in the information architecture.", - default=2) - def generate(self): edgelist, degrees = self.generate_edgelist() nodes, edgelist = self.assign_nodes(degrees, edgelist) - arch = self.generate_architecture(nodes, edgelist) - - return arch - - def assign_nodes(self, degrees, edgelist): - reordered = [] - for node_no in degrees.keys(): - reordered.append((node_no, degrees[node_no]['target'])) - - reordered.sort(key=operator.itemgetter(1)) - order = [] - for t in reordered: - order.append(t[0]) - - components = ['s'] * self.n_sensor_nodes + \ - ['sf'] * self.n_sensor_fusion_nodes + \ - ['f'] * self.n_fusion_nodes - - nodes = {} - n_sensors = 0 - n_trackers = 0 - for node_no, node_type in zip(order, components): - if node_type == 's': - lab = str(node_no) - sensor = copy.deepcopy(self.sensors[n_sensors]) + nodes, edgelist = self.add_network(nodes, edgelist) - for arch_no in range(self.n_archs): - node = SensorNode(sensor=sensor, label=lab) - #nodes[][node_no] = node - n_sensors += 1 + archs = list() + for architecture in nodes.keys(): + arch = self.generate_architecture(nodes[architecture], edgelist) + archs.append(arch) + return archs + def add_network(self, nodes, edgelist): + network_edgelist = [] + i = 0 + for e in edgelist: + # Choose number of routes between two information architecture nodes + n = self.n_routes[0] if len(self.n_routes) == 1 else \ + random.randint(self.n_routes[0], self.n_routes[1]) - elif node_type == 'f': - node = FusionNode(tracker=self.trackers[n_trackers], - fusion_queue=self.trackers[n_trackers].detector.reader, - label=str(node_no)) - n_trackers += 1 - nodes[node_no] = node + for route in range(n): + r_lab = 'r' + str(i) + for architecture in nodes.keys(): + r = RepeaterNode(label=r_lab) + network_edgelist.append((e[0], r_lab)) + network_edgelist.append((r_lab, e[1])) + nodes[architecture][r_lab] = r + i += 1 - elif node_type == 'sf': - node = SensorFusionNode(sensor=self.sensors[n_sensors], label=str(node_no), - tracker=self.trackers[n_trackers], - fusion_queue=self.trackers[n_trackers].detector.reader) - n_sensors += 1 - n_trackers += 1 - nodes[node_no] = node + return nodes, network_edgelist - new_edgelist = copy.copy(edgelist) - for edge in edgelist: - s = edge[0] - t = edge[1] + def generate_architecture(self, nodes, edgelist): + edges = [] + for t in edgelist: + edge = Edge((nodes[t[0]], nodes[t[1]])) + edges.append(edge) - if isinstance(nodes[s], FusionNode) and type(nodes[t]) == SensorNode: - new_edgelist.remove((s, t)) - new_edgelist.append((t, s)) + arch_edges = Edges(edges) - return nodes, new_edgelist \ No newline at end of file + arch = NetworkArchitecture(arch_edges, self.start_time) + return arch From 780bcc5ed5c0c8c6edf057d365e14ee6db798052 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 4 Apr 2024 11:42:10 +0100 Subject: [PATCH 114/170] Bug fixes for ArchitectureGenerator classes --- stonesoup/architecture/generator.py | 39 +++++++++++++++++++---------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index a86a3581a..327637a11 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -15,6 +15,8 @@ from stonesoup.base import Base, Property from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder from stonesoup.sensor.sensor import Sensor +from stonesoup.tracker import Tracker +from stonesoup.tracker.simple import MultiTargetTracker class InformationArchitectureGenerator(Base): @@ -45,7 +47,7 @@ class InformationArchitectureGenerator(Base): doc="Max distance each sensor can be from base_sensor.position. Should be a tuple of " "length equal to len(base_sensor.position_mapping)", default=None) - base_tracker: list = Property( + base_tracker: Tracker = Property( doc="Tracker class object that will be duplicated to create multiple trackers. Should have " "detector=None.", default=None) @@ -99,6 +101,8 @@ def generate_architecture(self, nodes, edgelist): return arch def assign_nodes(self, degrees, edgelist): + + # Order nodes by target degree (number of other nodes passing data to it) reordered = [] for node_no in degrees.keys(): reordered.append((node_no, degrees[node_no]['target'])) @@ -108,14 +112,24 @@ def assign_nodes(self, degrees, edgelist): for t in reordered: order.append(t[0]) + # Reorder so that nodes at the top of the information chain will not be sensor nodes if + # possible + top_nodes = [n for n in degrees.keys() if degrees[n]['source'] == 0] + for n in top_nodes: + order.remove(n) + order.append(n) + + # Order of s/sf/f nodes components = ['s'] * self.n_sensor_nodes + \ ['sf'] * self.n_sensor_fusion_nodes + \ ['f'] * self.n_fusion_nodes + # Create dictionary entry for each architecture copy nodes = {} for architecture in range(self.n_archs): nodes[architecture] = {} + # Create Nodes for node_no, node_type in zip(order, components): if node_type == 's': @@ -132,7 +146,6 @@ def assign_nodes(self, degrees, edgelist): elif node_type == 'f': for architecture in range(self.n_archs): t = copy.deepcopy(self.base_tracker) - fq = FusionQueue() t.detector = Tracks2GaussianDetectionFeeder(fq) @@ -152,26 +165,26 @@ def assign_nodes(self, degrees, edgelist): s.position = pos t = copy.deepcopy(self.base_tracker) - fq = FusionQueue() t.detector = Tracks2GaussianDetectionFeeder(fq) node = SensorFusionNode(sensor=s, tracker=t, - fusion_queue=fq) + fusion_queue=fq, + label=str(node_no)) nodes[architecture][node_no] = node new_edgelist = copy.copy(edgelist) - for edge in edgelist: - s = edge[0] - t = edge[1] - - if self.arch_type != 'hierarchical' and \ - isinstance(nodes[0][s], FusionNode) and \ - type(nodes[0][t]) == SensorNode: - new_edgelist.remove((s, t)) - new_edgelist.append((t, s)) + + if self.arch_type != 'hierarchical': + for edge in edgelist: + s = edge[0] + t = edge[1] + + if isinstance(nodes[0][s], FusionNode) and type(nodes[0][t]) == SensorNode: + new_edgelist.remove((s, t)) + new_edgelist.append((t, s)) return nodes, new_edgelist From 8c8e11d89aa032298f6681e149e3b0ab0c7b99af Mon Sep 17 00:00:00 2001 From: spike Date: Fri, 5 Apr 2024 13:57:31 +0100 Subject: [PATCH 115/170] Add tests for architecture generators --- stonesoup/architecture/generator.py | 46 ++-- stonesoup/architecture/tests/conftest.py | 77 +++++- .../architecture/tests/test_generator.py | 256 ++++++++++++++++++ 3 files changed, 351 insertions(+), 28 deletions(-) create mode 100644 stonesoup/architecture/tests/test_generator.py diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index 327637a11..e13ab13c0 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -3,7 +3,6 @@ import random import warnings -import networkx as nx from datetime import datetime import numpy as np @@ -16,7 +15,6 @@ from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder from stonesoup.sensor.sensor import Sensor from stonesoup.tracker import Tracker -from stonesoup.tracker.simple import MultiTargetTracker class InformationArchitectureGenerator(Base): @@ -48,8 +46,8 @@ class InformationArchitectureGenerator(Base): "length equal to len(base_sensor.position_mapping)", default=None) base_tracker: Tracker = Property( - doc="Tracker class object that will be duplicated to create multiple trackers. Should have " - "detector=None.", + doc="Tracker class object that will be duplicated to create multiple trackers. " + "Should have detector=None.", default=None) iteration_limit: int = Property( doc="Limit for the number of iterations the generate_edgelist() method can make when " @@ -79,17 +77,17 @@ def __init__(self, *args, **kwargs): raise ValueError('arch_style must be "decentralised" or "hierarchical"') def generate(self): - edgelist, degrees = self.generate_edgelist() + edgelist, degrees = self._generate_edgelist() - nodes, edgelist = self.assign_nodes(degrees, edgelist) + nodes, edgelist = self._assign_nodes(degrees, edgelist) archs = list() for architecture in nodes.keys(): - arch = self.generate_architecture(nodes[architecture], edgelist) + arch = self._generate_architecture(nodes[architecture], edgelist) archs.append(arch) return archs - def generate_architecture(self, nodes, edgelist): + def _generate_architecture(self, nodes, edgelist): edges = [] for t in edgelist: edge = Edge((nodes[t[0]], nodes[t[1]])) @@ -100,7 +98,7 @@ def generate_architecture(self, nodes, edgelist): arch = InformationArchitecture(arch_edges, self.start_time) return arch - def assign_nodes(self, degrees, edgelist): + def _assign_nodes(self, degrees, edgelist): # Order nodes by target degree (number of other nodes passing data to it) reordered = [] @@ -188,7 +186,7 @@ def assign_nodes(self, degrees, edgelist): return nodes, new_edgelist - def generate_edgelist(self): + def _generate_edgelist(self): count = 0 edges = [] sources = [] @@ -212,8 +210,8 @@ def generate_edgelist(self): targets = [] while i < self.n_edges: source, target = -1, -1 - while source == target or (source, target) in edges or ( - target, source) in edges: + while source == target or (source, target) in edges or \ + (target, source) in edges: source = random.randint(0, self.n_nodes-1) target = random.choice(list(nodes_used)) edge = (source, target) @@ -230,7 +228,7 @@ def generate_edgelist(self): if not network_found: if self.allow_invalid_graph: warnings.warn("Unable to find valid graph within iteration limit. Returned " - "network does not meet requirements") + "network does not meet requirements") else: raise ValueError("Unable to find valid graph within iteration limit. Returned " "network does not meet requirements") @@ -242,12 +240,6 @@ def generate_edgelist(self): return edges, degrees - @staticmethod - def plot_edges(edges): - G = nx.DiGraph() - G.add_edges_from(edges) - nx.draw(G) - class NetworkArchitectureGenerator(InformationArchitectureGenerator): """ @@ -260,19 +252,19 @@ class NetworkArchitectureGenerator(InformationArchitectureGenerator): default=(1, 2)) def generate(self): - edgelist, degrees = self.generate_edgelist() + edgelist, degrees = self._generate_edgelist() - nodes, edgelist = self.assign_nodes(degrees, edgelist) + nodes, edgelist = self._assign_nodes(degrees, edgelist) - nodes, edgelist = self.add_network(nodes, edgelist) + nodes, edgelist = self._add_network(nodes, edgelist) archs = list() for architecture in nodes.keys(): - arch = self.generate_architecture(nodes[architecture], edgelist) + arch = self._generate_architecture(nodes[architecture], edgelist) archs.append(arch) return archs - def add_network(self, nodes, edgelist): + def _add_network(self, nodes, edgelist): network_edgelist = [] i = 0 for e in edgelist: @@ -282,16 +274,16 @@ def add_network(self, nodes, edgelist): for route in range(n): r_lab = 'r' + str(i) + network_edgelist.append((e[0], r_lab)) + network_edgelist.append((r_lab, e[1])) for architecture in nodes.keys(): r = RepeaterNode(label=r_lab) - network_edgelist.append((e[0], r_lab)) - network_edgelist.append((r_lab, e[1])) nodes[architecture][r_lab] = r i += 1 return nodes, network_edgelist - def generate_architecture(self, nodes, edgelist): + def _generate_architecture(self, nodes, edgelist): edges = [] for t in edgelist: edge = Edge((nodes[t[0]], nodes[t[1]])) diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 41c1c30ca..c3b8f6549 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -1,5 +1,5 @@ import pytest - +import random import numpy as np from ordered_set import OrderedSet from datetime import datetime, timedelta @@ -364,3 +364,78 @@ def edge_lists(nodes, radar_nodes): "disconnected_loop_edges": disconnected_loop_edges, "repeater_edges": repeater_edges, "radar_edges": radar_edges, "sf_radar_edges": sf_radar_edges, "network_edges": network_edges} + + +@pytest.fixture() +def generator_params(): + start_time = datetime.now().replace(microsecond=0) + np.random.seed(1990) + random.seed(1990) + + from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, \ + ConstantVelocity + from stonesoup.types.groundtruth import GroundTruthPath, GroundTruthState + + # Generate transition model + transition_model = CombinedLinearGaussianTransitionModel([ConstantVelocity(0.005), + ConstantVelocity(0.005)]) + + yps = range(0, 100, 10) # y value for prior state + truths = OrderedSet() + ntruths = 3 # number of ground truths in simulation + time_max = 60 # timestamps the simulation is observed over + timesteps = [start_time + timedelta(seconds=k) for k in range(time_max)] + + xdirection = 1 + ydirection = 1 + + # Generate ground truths + for j in range(0, ntruths): + truth = GroundTruthPath([GroundTruthState([0, xdirection, yps[j], ydirection], + timestamp=timesteps[0])], id=f"id{j}") + + for k in range(1, time_max): + truth.append( + GroundTruthState(transition_model.function(truth[k - 1], noise=True, + time_interval=timedelta(seconds=1)), + timestamp=timesteps[k])) + truths.add(truth) + + xdirection *= -1 + if j % 2 == 0: + ydirection *= -1 + + base_sensor = RadarRotatingBearingRange( + position_mapping=(0, 2), + noise_covar=np.array([[np.radians(0.5) ** 2, 0], + [0, 1 ** 2]]), + ndim_state=4, + position=np.array([[10], [10]]), + rpm=60, + fov_angle=np.radians(360), + dwell_centre=StateVector([0.0]), + max_range=np.inf, + resolutions={'dwell_centre': Angle(np.radians(30))} + ) + + predictor = KalmanPredictor(transition_model) + updater = ExtendedKalmanUpdater(measurement_model=None) + hypothesiser = DistanceHypothesiser(predictor, updater, measure=Mahalanobis(), + missed_distance=5) + data_associator = GNNWith2DAssignment(hypothesiser) + deleter = CovarianceBasedDeleter(covar_trace_thresh=1) + initiator = MultiMeasurementInitiator( + prior_state=GaussianState([[0], [0], [0], [0]], np.diag([0, 1, 0, 1])), + measurement_model=None, + deleter=deleter, + data_associator=data_associator, + updater=updater, + min_points=2, + ) + + base_tracker = MultiTargetTracker(initiator, deleter, None, data_associator, updater) + + return {'start_time': start_time, + 'truths': truths, + 'base_sensor': base_sensor, + 'base_tracker': base_tracker} diff --git a/stonesoup/architecture/tests/test_generator.py b/stonesoup/architecture/tests/test_generator.py new file mode 100644 index 000000000..37cb834cf --- /dev/null +++ b/stonesoup/architecture/tests/test_generator.py @@ -0,0 +1,256 @@ +import pytest + +from stonesoup.architecture import Architecture +from stonesoup.architecture.edge import FusionQueue, Edges +from stonesoup.architecture.generator import InformationArchitectureGenerator, \ + NetworkArchitectureGenerator +from stonesoup.sensor.sensor import Sensor +from stonesoup.tracker import Tracker + + +def test_info_arch_gen_init(generator_params): + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + + gen = InformationArchitectureGenerator(start_time=start_time, + mean_degree=2, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor) + + # Test default values + assert gen.arch_type == 'decentralised' + assert gen.iteration_limit == 10000 + assert gen.allow_invalid_graph is False + assert gen.n_archs == 2 + + # Test variables set in __init__() + assert gen.n_sensor_nodes == 3 + assert gen.n_sensor_fusion_nodes == 1 + assert gen.n_fusion_nodes == 1 + + assert gen.sensor_max_distance == (0, 0) + + with pytest.raises(ValueError): + InformationArchitectureGenerator(arch_type='not_valid', + start_time=start_time, + mean_degree=2, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor) + + +def test_info_generate_hierarchical(generator_params): + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + + gen = InformationArchitectureGenerator(arch_type='hierarchical', + start_time=start_time, + mean_degree=2, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=3) + + archs = gen.generate() + + for arch in archs: + + # Check correct number of nodes + assert len(arch.all_nodes) == sum([3, 1, 1]) + + # Check node types + assert len(arch.fusion_nodes) == sum([1, 1]) + assert len(arch.sensor_nodes) == sum([3, 1]) + + assert len(arch.edges) == sum([3, 1, 1]) - 1 + + for node in arch.fusion_nodes: + # Check each fusion node has a tracker + assert isinstance(node.tracker, Tracker) + # Check each tracker has a FusionQueue + assert isinstance(node.tracker.detector.reader, FusionQueue) + + for node in arch.sensor_nodes: + # Check each sensor node has a Sensor + assert isinstance(node.sensor, Sensor) + + +def test_info_generate_decentralised(generator_params): + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + + gen = InformationArchitectureGenerator(arch_type='decentralised', + start_time=start_time, + mean_degree=2, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=3) + + archs = gen.generate() + + for arch in archs: + + # Check correct number of nodes + assert len(arch.all_nodes) == sum([3, 1, 1]) + + # Check node types + assert len(arch.fusion_nodes) == sum([1, 1]) + assert len(arch.sensor_nodes) == sum([3, 1]) + + assert len(arch.edges) == sum([3, 1, 1]) + + for node in arch.fusion_nodes: + # Check each fusion node has a tracker + assert isinstance(node.tracker, Tracker) + # Check each tracker has a FusionQueue + assert isinstance(node.tracker.detector.reader, FusionQueue) + + for node in arch.sensor_nodes: + # Check each sensor node has a Sensor + assert isinstance(node.sensor, Sensor) + + +def test_net_arch_gen_init(generator_params): + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + + gen = NetworkArchitectureGenerator(start_time=start_time, + mean_degree=2, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor) + + # Test default values + assert gen.arch_type == 'decentralised' + assert gen.iteration_limit == 10000 + assert gen.allow_invalid_graph is False + assert gen.n_archs == 2 + + # Test variables set in __init__() + assert gen.n_sensor_nodes == 3 + assert gen.n_sensor_fusion_nodes == 1 + assert gen.n_fusion_nodes == 1 + + assert gen.sensor_max_distance == (0, 0) + + with pytest.raises(ValueError): + NetworkArchitectureGenerator(arch_type='not_valid', + start_time=start_time, + mean_degree=2, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor) + + +def test_net_generate_hierarchical(generator_params): + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + + gen = NetworkArchitectureGenerator(arch_type='hierarchical', + start_time=start_time, + mean_degree=2, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=3) + + archs = gen.generate() + + for arch in archs: + + # Check correct number of nodes + assert len(arch.all_nodes) == sum([3, 1, 1]) + len(arch.repeater_nodes) + + # Check node types + assert len(arch.fusion_nodes) == sum([1, 1]) + assert len(arch.sensor_nodes) == sum([3, 1]) + + assert len(arch.edges) == 2 * len(arch.repeater_nodes) + + for node in arch.fusion_nodes: + # Check each fusion node has a tracker + assert isinstance(node.tracker, Tracker) + # Check each tracker has a FusionQueue + assert isinstance(node.tracker.detector.reader, FusionQueue) + + for node in arch.sensor_nodes: + # Check each sensor node has a Sensor + assert isinstance(node.sensor, Sensor) + + +def test_net_generate_decentralised(generator_params): + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + + gen = NetworkArchitectureGenerator(arch_type='decentralised', + start_time=start_time, + mean_degree=2, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=3) + + archs = gen.generate() + + for arch in archs: + + # Check correct number of nodes + assert len(arch.all_nodes) == sum([3, 1, 1]) + len(arch.repeater_nodes) + + # Check node types + assert len(arch.fusion_nodes) == sum([1, 1]) + assert len(arch.sensor_nodes) == sum([3, 1]) + for edge in arch.edges: + print((edge.nodes[0].label, edge.nodes[1].label)) + print(len(arch.repeater_nodes)) + assert len(arch.edges) == 2 * len(arch.repeater_nodes) + + for node in arch.fusion_nodes: + # Check each fusion node has a tracker + assert isinstance(node.tracker, Tracker) + # Check each tracker has a FusionQueue + assert isinstance(node.tracker.detector.reader, FusionQueue) + + for node in arch.sensor_nodes: + # Check each sensor node has a Sensor + assert isinstance(node.sensor, Sensor) + + +def test_invalid_graph(generator_params): + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + + gen = NetworkArchitectureGenerator(arch_type='decentralised', + start_time=start_time, + mean_degree=0.5, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=3, + allow_invalid_graph=False) + + with pytest.raises(ValueError): + gen.generate() + + gen2 = NetworkArchitectureGenerator(arch_type='decentralised', + start_time=start_time, + mean_degree=0.5, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=3, + allow_invalid_graph=True) + archs = gen2.generate() + + for arch in archs: + assert isinstance(arch, Architecture) + assert isinstance(arch.edges, Edges) From 1eaf361b95ededf12e85168e539ecceea1905a17 Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 10 Apr 2024 10:12:57 +0100 Subject: [PATCH 116/170] Add node labels to nodes generated by ArchitectureGenerator classes --- stonesoup/architecture/generator.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index e13ab13c0..993e6fa08 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -138,7 +138,9 @@ def _assign_nodes(self, degrees, edgelist): s = copy.deepcopy(self.base_sensor) s.position = pos - node = SensorNode(sensor=s, label=str(node_no)) + node = SensorNode(sensor=s, + label=str(node_no), + latency=0) nodes[architecture][node_no] = node elif node_type == 'f': @@ -149,7 +151,8 @@ def _assign_nodes(self, degrees, edgelist): node = FusionNode(tracker=t, fusion_queue=fq, - label=str(node_no)) + label=str(node_no), + latency=0) nodes[architecture][node_no] = node @@ -169,7 +172,8 @@ def _assign_nodes(self, degrees, edgelist): node = SensorFusionNode(sensor=s, tracker=t, fusion_queue=fq, - label=str(node_no)) + label=str(node_no), + latency=0) nodes[architecture][node_no] = node From 583f215115c5ae34191ba248c2bd99cac78ebcaa Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 15 Apr 2024 13:38:05 +0100 Subject: [PATCH 117/170] Add detail to architecture tutorials --- ...2_Information_and_Network_Architectures.py | 33 ++++++++++++++- .../architecture/03_Avoiding_Data_Incest.py | 42 ++++++++++++++----- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 4bea534a1..101667a25 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -16,11 +16,36 @@ # %% # Introduction # ------------ +# Intro to Information and Network Architectures +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# A common example of this is considering posting a letter. You may write some +# information, put it in an envelope, and post it to a friend who receives, opens, and processes +# the information that you sent. You and your friend (and anyone else who your friend passes +# the information to) are nodes in the information architecture. Information architectures only +# concern nodes that open, process and/or fuse data. # +# Network Architectures consider the underlying framework that enables propagation of information. +# In our example, consider the post-box, sorting office and delivery vans - these are all nodes +# in the network architecture. These nodes do not open and process any of the information that is +# being sent, but they are present in the network and have an effect on the route the information +# takes to reach its destination, and how long it takes to get there. +# +# Intro to this Tutorial +# ^^^^^^^^^^^^^^^^^^^^^^ # Following on from Tutorial 01: Introduction to Architectures in Stone Soup, this tutorial # provides a more in-depth walk through of generating and simulating information and network # architectures. # +# We will first create a representation of an information architecture using +# :class:`~.InformationArchitecture` and use it to show how we can simulate detection, +# propagation and fusion of data. +# +# We will then design a network architecture that could uphold the already displayed information +# architecture. To do this we will use :class:`~.NetworkArchitecture`. Additionally, we will use +# the property `information_arch` of :class:`~.NetworkArchitecture` to show that the +# overlying information architecture linked to the created network architecture is identical to +# the information architecture that we started with. +# # We start by generating a set of sensors, and a ground truth from which the sensors will make # measurements. We will make use of these in both information and network architecture examples # later in the tutorial. @@ -219,9 +244,11 @@ # %% # Build and plot Information Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - +# # sphinx_gallery_thumbnail_path = '_static/sphinx_gallery/ArchTutorial_2.png' - +# +# Here we can see the Information Architecture that we have built. We can next run a simulation +# over this architecture and plot the tracking results. information_architecture = InformationArchitecture(edges, current_time=start_time) information_architecture @@ -402,6 +429,8 @@ def reduce_tracks(tracks): # to a physical edge between two nodes in the Network Architecture, but it could also be a # representation of multiple edges and nodes that a data piece would be transferred through in # order to pass from a sender to a recipient node. +# Observing the plot of the information architecture, we can see that it is identical to the +# information architecture that we started this tutorial with. network_architecture.information_arch diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 5a32ba713..e20948245 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -20,7 +20,9 @@ # can occur in a poorly designed network. # # We design two architectures: a centralised (non-hierarchical) architecture, and a hierarchical -# alternative, and look to compare the fused results at the central node. +# alternative, and look to compare the fused results at the central node. We expect to show that +# non-hierarchical architecture will have poor tracking performance in comparison to the +# hierarchical alternative. # # Scenario generation # ------------------- @@ -170,7 +172,18 @@ # Non-Hierarchical Architecture # ----------------------------- # -# We start by constructing the non-hierarchical, centralised architecture. +# We start by constructing the non-hierarchical, centralised architecture. This architecture +# consists of 3 nodes: one sensor node, which sends data to a fusion node, and a sensor fusion +# node. This sensor fusion performs two operations: +# a) recording its own data from its sensor, and +# b) fusing its own data with the data received from the sensor node. +# These detections and fused outputs are passed on to the same fusion node mentioned before. +# The fusion node receives data from both the sensor node and sensor fusion node. The data +# passed from the sensor fusion node will contain information that was calculated by processing +# the detections from the sensor node. This means that the information created by the sensor node +# is arriving at the fusion node via two routes, but is being treated as if it is from separate +# sources. This can cause a bias towards the data generated at the sensor node, rather than that +# created at the sensor fusion node. This is an example of data incest. # # Nodes # ^^^^^ @@ -237,6 +250,11 @@ # %% # Plot the tracks stored at Non-Hierarchical Node C # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Inspecting the plot below shows multiple tracks. The extra delay incurred by passing data along +# multiple edges is causing enough of a delay that the multi-target tracker is identifying the +# delayed measurements as a completely different target. This has a negative impact in tracking +# performance and metrics. Later in this example, a plot of the hierarchical architecture tracks +# should show this issue is resolved. from stonesoup.plotter import Plotterly @@ -259,9 +277,10 @@ def reduce_tracks(tracks): # Hierarchical Architecture # ------------------------- # -# We now create an alternative architecture. We recreate the same set of nodes as before, but -# with a new edge set, which is a subset of the edge set used in the non-hierarchical -# architecture. +# We now create an alternative architecture. We recreate the same set of nodes as before. We use +# the same set of edges as before, but remove the edge from the sensor node to the fusion node. +# This change stops the same information that is generated at the sensor node, from reaching the +# fusion node in two forms. This avoids the possibility of data incest from occurring. # # Regenerate Nodes identical to those in the non-hierarchical example # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -322,6 +341,9 @@ def reduce_tracks(tracks): # %% # Plot the tracks stored at Hierarchical Node C # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# The plot of tracks from the hierarchical architecture should show that the issue with multiple +# tracks being initiated has been resolved. This is due to the removal of the edge causing extra +# delay. plotter = Plotterly() plotter.plot_ground_truths(truths, [0, 2]) @@ -377,11 +399,11 @@ def reduce_tracks(tracks): # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ H_siap_EKF_truth = SIAPMetrics(position_measure=Euclidean((0, 2)), - velocity_measure=Euclidean((1, 3)), - generator_name='H_SIAP_EKF-truth', - tracks_key='H_EKF_tracks', - truths_key='truths' - ) + velocity_measure=Euclidean((1, 3)), + generator_name='H_SIAP_EKF-truth', + tracks_key='H_EKF_tracks', + truths_key='truths' + ) associator = TrackToTruth(association_threshold=30) From 08a13a0bc9e637df18ffad5877db3ea1d400b7f5 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 15 Apr 2024 08:56:48 +0100 Subject: [PATCH 118/170] progress towards architecture generator bugs --- stonesoup/architecture/generator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index 993e6fa08..395838a04 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -5,6 +5,7 @@ from datetime import datetime +import networkx as nx import numpy as np from stonesoup.architecture import SensorNode, FusionNode, Edge, Edges, InformationArchitecture, \ From bef7265656996124aa5d1f4114de45f9fede5179 Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 17 Apr 2024 17:12:31 +0100 Subject: [PATCH 119/170] Fix bug in Architecture.propagate causing unintended lag/OOSM --- stonesoup/architecture/__init__.py | 26 ++++++++++++++++++-------- stonesoup/architecture/edge.py | 4 +++- stonesoup/architecture/generator.py | 2 -- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index cfca4f7cb..36d02a286 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -408,21 +408,24 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): if failed_edges and edge in failed_edges: edge._failed(self.current_time, time_increment) continue # No data passed along these edges + + # Initial update of message categories edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) # fuse goes here? for data_piece, time_pertaining in edge.unsent_data: edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) + # Need to re-run update messages so that messages aren't left as 'pending' + edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) + # for node in self.processing_nodes: # node.process() # This should happen when a new message is received + for fuse_node in self.fusion_nodes: + fuse_node.fuse() if self.fully_propagated: - for fuse_node in self.fusion_nodes: - fuse_node.fuse() - self.current_time += timedelta(seconds=time_increment) return - else: self.propagate(time_increment, failed_edges) @@ -498,6 +501,7 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): edge._failed(self.current_time, time_increment) continue # No data passed along these edges + # Initial update of message categories if edge.recipient not in self.information_arch.all_nodes: edge.update_messages(self.current_time, to_network_node=True, use_arrival_time=self.use_arrival_time) @@ -513,15 +517,21 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): if edge.recipient not in message.data_piece.sent_to: edge.pass_message(message) + # Need to re-run update messages so that messages aren't left as 'pending' + if edge.recipient not in self.information_arch.all_nodes: + edge.update_messages(self.current_time, to_network_node=True, + use_arrival_time=self.use_arrival_time) + else: + edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) + # for node in self.processing_nodes: # node.process() # This should happen when a new message is received - if self.fully_propagated: - for fuse_node in self.fusion_nodes: - fuse_node.fuse() + for fuse_node in self.fusion_nodes: + fuse_node.fuse() + if self.fully_propagated: self.current_time += timedelta(seconds=time_increment) return - else: self.propagate(time_increment, failed_edges) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 9d47af09e..718d117fb 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -209,7 +209,9 @@ def unsent_data(self): if isinstance(type(self.sender.data_held), type(None)) or self.sender.data_held is None: return unsent else: - for status in ["fused", "created"]: + for status in ["fused"] if \ + str(type(self.nodes[0])).split('.')[-1].split("'")[0] == 'SensorFusionNode' \ + else ["fused", "created"]: for time_pertaining in self.sender.data_held[status]: for data_piece in self.sender.data_held[status][time_pertaining]: # Data will be sent to any nodes it hasn't been sent to before diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index 395838a04..c5453a594 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -4,8 +4,6 @@ import warnings from datetime import datetime - -import networkx as nx import numpy as np from stonesoup.architecture import SensorNode, FusionNode, Edge, Edges, InformationArchitecture, \ From 378e75516ecb31a8edcb1abbf540b088e0606338 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 18 Apr 2024 10:30:40 +0100 Subject: [PATCH 120/170] Rework to ArchitectureGenerator logic to produce more feasible architectures --- stonesoup/architecture/edge.py | 9 +- stonesoup/architecture/generator.py | 211 +++++++++--------- .../architecture/tests/test_generator.py | 54 +---- 3 files changed, 118 insertions(+), 156 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 718d117fb..0404bca28 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -204,14 +204,17 @@ def unpassed_data(self): @property def unsent_data(self): + from stonesoup.architecture.node import SensorFusionNode """Data held by the sender that has not been sent to the recipient.""" unsent = [] if isinstance(type(self.sender.data_held), type(None)) or self.sender.data_held is None: return unsent else: - for status in ["fused"] if \ - str(type(self.nodes[0])).split('.')[-1].split("'")[0] == 'SensorFusionNode' \ - else ["fused", "created"]: + # for status in ["fused"] if \ + # str(type(self.nodes[0])).split('.')[-1].split("'")[0] == 'SensorFusionNode' \ + # else ["fused", "created"]: + for status in ["fused"] if isinstance(self.nodes[0], SensorFusionNode) else \ + ["fused", "created"]: for time_pertaining in self.sender.data_held[status]: for data_piece in self.sender.data_held[status][time_pertaining]: # Data will be sent to any nodes it hasn't been sent to before diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index c5453a594..413d44c76 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -1,9 +1,9 @@ import copy -import operator import random -import warnings from datetime import datetime + +import networkx as nx import numpy as np from stonesoup.architecture import SensorNode, FusionNode, Edge, Edges, InformationArchitecture, \ @@ -68,7 +68,7 @@ def __init__(self, *args, **kwargs): self.n_fusion_nodes = self.node_ratio[2] self.n_sensor_fusion_nodes = self.node_ratio[1] - self.n_edges = np.ceil(self.n_nodes * self.mean_degree * 0.5) + self.n_edges = int(np.ceil(self.n_nodes * self.mean_degree * 0.5)) if self.sensor_max_distance is None: self.sensor_max_distance = tuple(np.zeros(len(self.base_sensor.position_mapping))) @@ -76,9 +76,9 @@ def __init__(self, *args, **kwargs): raise ValueError('arch_style must be "decentralised" or "hierarchical"') def generate(self): - edgelist, degrees = self._generate_edgelist() + edgelist, node_labels = self._generate_edgelist() - nodes, edgelist = self._assign_nodes(degrees, edgelist) + nodes = self._assign_nodes(node_labels) archs = list() for architecture in nodes.keys(): @@ -97,52 +97,15 @@ def _generate_architecture(self, nodes, edgelist): arch = InformationArchitecture(arch_edges, self.start_time) return arch - def _assign_nodes(self, degrees, edgelist): - - # Order nodes by target degree (number of other nodes passing data to it) - reordered = [] - for node_no in degrees.keys(): - reordered.append((node_no, degrees[node_no]['target'])) - - reordered.sort(key=operator.itemgetter(1)) - order = [] - for t in reordered: - order.append(t[0]) - - # Reorder so that nodes at the top of the information chain will not be sensor nodes if - # possible - top_nodes = [n for n in degrees.keys() if degrees[n]['source'] == 0] - for n in top_nodes: - order.remove(n) - order.append(n) - - # Order of s/sf/f nodes - components = ['s'] * self.n_sensor_nodes + \ - ['sf'] * self.n_sensor_fusion_nodes + \ - ['f'] * self.n_fusion_nodes + def _assign_nodes(self, node_labels): - # Create dictionary entry for each architecture copy nodes = {} for architecture in range(self.n_archs): nodes[architecture] = {} - # Create Nodes - for node_no, node_type in zip(order, components): + for label in node_labels: - if node_type == 's': - pos = np.array( - [[p + random.uniform(-d, d)] for p, d in zip(self.base_sensor.position, - self.sensor_max_distance)]) - for architecture in range(self.n_archs): - s = copy.deepcopy(self.base_sensor) - s.position = pos - - node = SensorNode(sensor=s, - label=str(node_no), - latency=0) - nodes[architecture][node_no] = node - - elif node_type == 'f': + if label.startswith('f'): for architecture in range(self.n_archs): t = copy.deepcopy(self.base_tracker) fq = FusionQueue() @@ -150,12 +113,13 @@ def _assign_nodes(self, degrees, edgelist): node = FusionNode(tracker=t, fusion_queue=fq, - label=str(node_no), + label=label, latency=0) - nodes[architecture][node_no] = node + nodes[architecture][label] = node + + elif label.startswith('sf'): - elif node_type == 'sf': pos = np.array( [[p + random.uniform(-d, d)] for p, d in zip(self.base_sensor.position, self.sensor_max_distance)]) @@ -171,77 +135,104 @@ def _assign_nodes(self, degrees, edgelist): node = SensorFusionNode(sensor=s, tracker=t, fusion_queue=fq, - label=str(node_no), + label=label, latency=0) - nodes[architecture][node_no] = node + nodes[architecture][label] = node - new_edgelist = copy.copy(edgelist) + elif label.startswith('s'): + pos = np.array( + [[p + random.uniform(-d, d)] for p, d in zip(self.base_sensor.position, + self.sensor_max_distance)]) + for architecture in range(self.n_archs): + s = copy.deepcopy(self.base_sensor) + s.position = pos - if self.arch_type != 'hierarchical': - for edge in edgelist: - s = edge[0] - t = edge[1] + node = SensorNode(sensor=s, + label=label, + latency=0) + nodes[architecture][label] = node - if isinstance(nodes[0][s], FusionNode) and type(nodes[0][t]) == SensorNode: - new_edgelist.remove((s, t)) - new_edgelist.append((t, s)) + elif label.startswith('r'): + for architecture in range(self.n_archs): + node = RepeaterNode(label=label, + latency=0) + nodes[architecture][label] = node - return nodes, new_edgelist + return nodes def _generate_edgelist(self): - count = 0 + edges = [] - sources = [] - targets = [] + + nodes = ['f' + str(i) for i in range(self.n_fusion_nodes)] + \ + ['sf' + str(i) for i in range(self.n_sensor_fusion_nodes)] + \ + ['s' + str(i) for i in range(self.n_sensor_nodes)] + + valid = False + if self.arch_type == 'hierarchical': - for i in range(1, self.n_nodes): - target = random.randint(0, i - 1) - source = i - edge = (source, target) - edges.append(edge) - sources.append(source) - targets.append(target) - - if self.arch_type == 'decentralised': - network_found = False - while network_found is False and count < self.iteration_limit: - i = 0 - nodes_used = {0} + while not valid: edges = [] - sources = [] - targets = [] - while i < self.n_edges: - source, target = -1, -1 - while source == target or (source, target) in edges or \ - (target, source) in edges: - source = random.randint(0, self.n_nodes-1) - target = random.choice(list(nodes_used)) - edge = (source, target) + n = self.n_fusion_nodes + self.n_sensor_fusion_nodes + + for i, node in enumerate(nodes): + if i == 0: + continue + elif i == 1: + source = nodes[0] + target = nodes[1] + else: + if node.startswith('s') and not node.startswith('sf'): + source = node + target = nodes[random.randint(0, n - 1)] + else: + source = node + target = nodes[random.randint(0, i - 1)] + + # Create edge + edge = (source, target) edges.append(edge) - nodes_used |= {source, target} - sources.append(source) - targets.append(target) - i += 1 - if len(nodes_used) == self.n_nodes: - network_found = True - count += 1 + # Logic checks on graph + g = nx.DiGraph(edges) + for f_node in ['f' + str(i) for i in range(self.n_fusion_nodes)]: + if g.in_degree(f_node) == 0: + valid = False + break + else: + valid = True + + elif self.arch_type == 'decentralised': + + while not valid: + + for i in range(1, self.n_edges + 1): + source = target = -1 + if i < self.n_nodes: + source = nodes[i] + target = nodes[random.randint( + 0, min(i - 1, len(nodes) - self.n_sensor_nodes - 1))] + + else: + while source == target \ + or (source, target) in edges \ + or (target, source) in edges: + source = nodes[random.randint(0, len(nodes) - 1)] + target = nodes[random.randint(0, len(nodes) - self.n_sensor_nodes - 1)] - if not network_found: - if self.allow_invalid_graph: - warnings.warn("Unable to find valid graph within iteration limit. Returned " - "network does not meet requirements") - else: - raise ValueError("Unable to find valid graph within iteration limit. Returned " - "network does not meet requirements") + edges.append((source, target)) - degrees = {} - for node in range(self.n_nodes): - degrees[node] = {'source': sources.count(node), 'target': targets.count(node), - 'degree': sources.count(node) + targets.count(node)} + # Logic checks on graph + g = nx.DiGraph(edges) + for f_node in ['f' + str(i) for i in range(self.n_fusion_nodes)]: + if g.in_degree(f_node) == 0: + valid = False + break + else: + valid = True - return edges, degrees + return edges, nodes class NetworkArchitectureGenerator(InformationArchitectureGenerator): @@ -255,11 +246,11 @@ class NetworkArchitectureGenerator(InformationArchitectureGenerator): default=(1, 2)) def generate(self): - edgelist, degrees = self._generate_edgelist() + edgelist, node_labels = self._generate_edgelist() - nodes, edgelist = self._assign_nodes(degrees, edgelist) + edgelist, node_labels = self._add_network(edgelist, node_labels) - nodes, edgelist = self._add_network(nodes, edgelist) + nodes = self._assign_nodes(node_labels) archs = list() for architecture in nodes.keys(): @@ -267,7 +258,7 @@ def generate(self): archs.append(arch) return archs - def _add_network(self, nodes, edgelist): + def _add_network(self, edgelist, nodes): network_edgelist = [] i = 0 for e in edgelist: @@ -279,12 +270,10 @@ def _add_network(self, nodes, edgelist): r_lab = 'r' + str(i) network_edgelist.append((e[0], r_lab)) network_edgelist.append((r_lab, e[1])) - for architecture in nodes.keys(): - r = RepeaterNode(label=r_lab) - nodes[architecture][r_lab] = r + nodes.append(r_lab) i += 1 - return nodes, network_edgelist + return network_edgelist, nodes def _generate_architecture(self, nodes, edgelist): edges = [] diff --git a/stonesoup/architecture/tests/test_generator.py b/stonesoup/architecture/tests/test_generator.py index 37cb834cf..2fbba3db2 100644 --- a/stonesoup/architecture/tests/test_generator.py +++ b/stonesoup/architecture/tests/test_generator.py @@ -1,7 +1,7 @@ +import numpy as np import pytest -from stonesoup.architecture import Architecture -from stonesoup.architecture.edge import FusionQueue, Edges +from stonesoup.architecture.edge import FusionQueue from stonesoup.architecture.generator import InformationArchitectureGenerator, \ NetworkArchitectureGenerator from stonesoup.sensor.sensor import Sensor @@ -49,7 +49,7 @@ def test_info_generate_hierarchical(generator_params): gen = InformationArchitectureGenerator(arch_type='hierarchical', start_time=start_time, mean_degree=2, - node_ratio=[3, 1, 1], + node_ratio=[2, 2, 1], base_tracker=base_tracker, base_sensor=base_sensor, n_archs=3) @@ -59,13 +59,13 @@ def test_info_generate_hierarchical(generator_params): for arch in archs: # Check correct number of nodes - assert len(arch.all_nodes) == sum([3, 1, 1]) + assert len(arch.all_nodes) == sum([2, 2, 1]) # Check node types - assert len(arch.fusion_nodes) == sum([1, 1]) - assert len(arch.sensor_nodes) == sum([3, 1]) + assert len(arch.fusion_nodes) == sum([2, 1]) + assert len(arch.sensor_nodes) == sum([2, 2]) - assert len(arch.edges) == sum([3, 1, 1]) - 1 + assert len(arch.edges) == sum([2, 2, 1]) - 1 for node in arch.fusion_nodes: # Check each fusion node has a tracker @@ -83,13 +83,15 @@ def test_info_generate_decentralised(generator_params): base_sensor = generator_params['base_sensor'] base_tracker = generator_params['base_tracker'] + mean_deg = 2.5 + gen = InformationArchitectureGenerator(arch_type='decentralised', start_time=start_time, - mean_degree=2, + mean_degree=mean_deg, node_ratio=[3, 1, 1], base_tracker=base_tracker, base_sensor=base_sensor, - n_archs=3) + n_archs=2) archs = gen.generate() @@ -102,7 +104,7 @@ def test_info_generate_decentralised(generator_params): assert len(arch.fusion_nodes) == sum([1, 1]) assert len(arch.sensor_nodes) == sum([3, 1]) - assert len(arch.edges) == sum([3, 1, 1]) + assert len(arch.edges) == np.ceil(sum([3, 1, 1]) * mean_deg * 0.5) for node in arch.fusion_nodes: # Check each fusion node has a tracker @@ -222,35 +224,3 @@ def test_net_generate_decentralised(generator_params): for node in arch.sensor_nodes: # Check each sensor node has a Sensor assert isinstance(node.sensor, Sensor) - - -def test_invalid_graph(generator_params): - start_time = generator_params['start_time'] - base_sensor = generator_params['base_sensor'] - base_tracker = generator_params['base_tracker'] - - gen = NetworkArchitectureGenerator(arch_type='decentralised', - start_time=start_time, - mean_degree=0.5, - node_ratio=[3, 1, 1], - base_tracker=base_tracker, - base_sensor=base_sensor, - n_archs=3, - allow_invalid_graph=False) - - with pytest.raises(ValueError): - gen.generate() - - gen2 = NetworkArchitectureGenerator(arch_type='decentralised', - start_time=start_time, - mean_degree=0.5, - node_ratio=[3, 1, 1], - base_tracker=base_tracker, - base_sensor=base_sensor, - n_archs=3, - allow_invalid_graph=True) - archs = gen2.generate() - - for arch in archs: - assert isinstance(arch, Architecture) - assert isinstance(arch.edges, Edges) From 7d92bd2906091a0b4c275829a09af55381065aaf Mon Sep 17 00:00:00 2001 From: Oliver Rosoman <95758965+orosoman-dstl@users.noreply.github.com> Date: Tue, 23 Apr 2024 17:53:04 +0100 Subject: [PATCH 121/170] Apply easier to address suggestions from #903 code review Co-authored-by: Christopher Sherman <146717651+csherman-dstl@users.noreply.github.com> Co-authored-by: Steven Hiscocks --- .../architecture/01_Introduction_to_Architectures.py | 2 +- stonesoup/architecture/edge.py | 3 +-- stonesoup/architecture/node.py | 6 ++++-- stonesoup/base.py | 1 - stonesoup/feeder/track.py | 3 ++- stonesoup/updater/wrapper.py | 1 + 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/tutorials/architecture/01_Introduction_to_Architectures.py b/docs/tutorials/architecture/01_Introduction_to_Architectures.py index 9d8aaddc9..b45414bf7 100644 --- a/docs/tutorials/architecture/01_Introduction_to_Architectures.py +++ b/docs/tutorials/architecture/01_Introduction_to_Architectures.py @@ -38,7 +38,7 @@ # architecture. # # - :class:`.~FusionNode`: receives data from child nodes, and fuses to achieve a fused result. -# Fused result can be propagated onwards. +# The fused result can be propagated onwards. # # - :class:`.~SensorFusionNode`: has the functionality of both a SensorNode and a FusionNode. # diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 0404bca28..6259b4a16 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -95,8 +95,7 @@ def send_message(self, data_piece, time_pertaining, time_sent): :param time_sent: Time at which the message was sent """ if not isinstance(data_piece, DataPiece): - raise TypeError("Message info must be one of the following types: " - "Detection, Hypothesis or Track") + raise TypeError(f"data_piece is type {type(data_piece}. Expected DataPiece") # Add message to 'pending' dict of edge # data_to_send = copy.deepcopy(data_piece.data) # new_datapiece = DataPiece(data_piece.node, data_piece.originator, data_to_send, diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index 069d2efc7..6c155a95b 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -100,8 +100,8 @@ class FusionNode(Node): tracker: Tracker = Property( doc="Tracker used by this Node to fuse together Tracks and Detections") fusion_queue: FusionQueue = Property( - default=FusionQueue(), - doc="The queue from which this node draws data to be fused") + default=None, + doc="The queue from which this node draws data to be fused. Default is a standard FusionQueue") tracks: set = Property(default=None, doc="Set of tracks tracked by the fusion node") colour: str = Property( @@ -114,6 +114,8 @@ class FusionNode(Node): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.tracks = set() # Set of tracks this Node has recorded + if not self.fusion_queue: + self.fusion_queue = FusionQueue() self._track_queue = Queue() self._tracking_thread = threading.Thread( diff --git a/stonesoup/base.py b/stonesoup/base.py index a5e453441..f68afa4fd 100644 --- a/stonesoup/base.py +++ b/stonesoup/base.py @@ -61,7 +61,6 @@ def __init__(self, foo, bar=bar.default, *args, **kwargs): from copy import copy from functools import cached_property from types import MappingProxyType -# from typing import TYPE_CHECKING class Property: diff --git a/stonesoup/feeder/track.py b/stonesoup/feeder/track.py index 5d052411d..c192402b4 100644 --- a/stonesoup/feeder/track.py +++ b/stonesoup/feeder/track.py @@ -35,7 +35,8 @@ def data_gen(self): target_type=GaussianDetection) ) else: - # Assume it's a detection + if not isinstance(track, Detection): + raise TypeError(f"track is of type {type(track}. Expected Track or Detection") detections.add(track) yield time, detections diff --git a/stonesoup/updater/wrapper.py b/stonesoup/updater/wrapper.py index 92b029749..bb8d7b308 100644 --- a/stonesoup/updater/wrapper.py +++ b/stonesoup/updater/wrapper.py @@ -4,6 +4,7 @@ class DetectionAndTrackSwitchingUpdater(Updater): +"""Updater which sorts between Detections and Tracks""" detection_updater: Updater = Property() track_updater: Updater = Property() From c539359506267afd9a486d7c881273f641d1b996 Mon Sep 17 00:00:00 2001 From: spike Date: Wed, 24 Apr 2024 14:49:20 +0100 Subject: [PATCH 122/170] Change to SiapDiffTableGenerator to allow more than 2 sets of metrics --- stonesoup/metricgenerator/metrictables.py | 106 +++++++++++++++------- 1 file changed, 71 insertions(+), 35 deletions(-) diff --git a/stonesoup/metricgenerator/metrictables.py b/stonesoup/metricgenerator/metrictables.py index e7616430b..f02dca01e 100644 --- a/stonesoup/metricgenerator/metrictables.py +++ b/stonesoup/metricgenerator/metrictables.py @@ -6,7 +6,7 @@ from matplotlib import pyplot as plt from .base import MetricTableGenerator, MetricGenerator -from ..base import Property +from ..base import Property, Base class RedGreenTableGenerator(MetricTableGenerator): @@ -137,15 +137,25 @@ def set_default_descriptions(self): } -class SiapDiffTableGenerator(SIAPTableGenerator): +class SiapDiffTableGenerator(Base): """ Given two sets of metric generators, the SiapDiffTableGenerator returns a table displaying the difference between two sets of metrics. Allows quick comparison of two sets of metrics. """ - metrics2: Collection[MetricGenerator] = Property(doc="Set of metrics to put in the table") + metrics: list[Collection[MetricGenerator]] = Property(doc="Set of metrics to put in the table") + + metrics_labels: list[str] = Property(doc='TODO', + default=None) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.targets = None + self.descriptions = None + self.set_default_targets() + self.set_default_descriptions() + if self.metrics_labels is None: + self.metrics_labels = ['Metrics ' + str(i) + ' Value' + for i in range(1, len(self.metrics) + 1)] def compute_metric(self, **kwargs): """Generate table method @@ -157,45 +167,41 @@ def compute_metric(self, **kwargs): displaying the difference between the pair of metric values.""" white = (1, 1, 1) - cellText = [["Metric", "Description", "Target", "Metrics 1 Value", "Metrics 2 Value", - "Diff"]] - cellColors = [[white, white, white, white, white, white]] + cellText = [["Metric", "Description", "Target"] + self.metrics_labels + ["Max Diff"]] + cellColors = [[white] * (4 + len(self.metrics))] - t1_metrics = sorted(self.metrics, key=attrgetter('title')) - t2_metrics = sorted(self.metrics2, key=attrgetter('title')) + sorted_metrics = [sorted(metric_gens, key=attrgetter('title')) + for metric_gens in self.metrics] - for t1metric, t2metric in zip(t1_metrics, t2_metrics): - # Add metric details to table row - metric_name = t1metric.title + for row_num in range(len(sorted_metrics[0])): + row_metrics = [m[row_num] for m in sorted_metrics] + + metric_name = row_metrics[0].title description = self.descriptions[metric_name] target = self.targets[metric_name] - t1value = t1metric.value - t2value = t2metric.value - diff = round(t1value - t2value, ndigits=2) - cellText.append([metric_name, description, target, "{:.2f}".format(t1value), - "{:.2f}".format(t2value), diff]) - # Generate color for value cell based on closeness to target value - # Closeness to infinity cannot be represented as a color - if target is not None and not target == np.inf: - red_value1 = 1 - green_value1 = 1 - red_value2 = 1 - green_value2 = 1 - # A value of 1 for both red & green produces yellow + values = [metric.value for metric in row_metrics] - if abs(t1value - target) < abs(t2value - target): - red_value1 = 0 - green_value2 = 0 - elif abs(t1value - target) > abs(t2value - target): - red_value2 = 0 - green_value1 = 0 + diff = round(max(values) - min(values), ndigits=3) - cellColors.append( - [white, white, white, (red_value1, green_value1, 0, 0.5), - (red_value2, green_value2, 0, 0.5), white]) - else: - cellColors.append([white, white, white, white, white, white]) + row_vals = [metric_name, description, target] + \ + ["{:.2f}".format(value) for value in values] + [diff] + + cellText.append(row_vals) + + colours = [] + for i, value in enumerate(values): + other_values = values[:i] + values[i+1:] + if all(num <= 0 for num in [abs(value - target) - abs(v - target) + for v in other_values]): + colours.append((0, 1, 0, 0.5)) + elif all(num >= 0 for num in [abs(value - target) - abs(v - target) + for v in other_values]): + colours.append((1, 0, 0, 0.5)) + else: + colours.append((1, 1, 0, 0.5)) + + cellColors.append([white, white, white] + colours + [white]) # "Plot" table scale = (1, 3) @@ -207,3 +213,33 @@ def compute_metric(self, **kwargs): table.scale(*scale) return table + + def set_default_targets(self): + self.targets = { + "SIAP Completeness": 1, + "SIAP Ambiguity": 1, + "SIAP Spuriousness": 0, + "SIAP Position Accuracy": 0, + "SIAP Velocity Accuracy": 0, + "SIAP Rate of Track Number Change": 0, + "SIAP Longest Track Segment": 1, + "SIAP ID Completeness": 1, + "SIAP ID Correctness": 1, + "SIAP ID Ambiguity": 0 + } + + def set_default_descriptions(self): + self.descriptions = { + "SIAP Completeness": "Fraction of true objects being tracked", + "SIAP Ambiguity": "Number of tracks assigned to a true object", + "SIAP Spuriousness": "Fraction of tracks that are unassigned to a true object", + "SIAP Position Accuracy": "Positional error of associated tracks to their respective " + "truths", + "SIAP Velocity Accuracy": "Velocity error of associated tracks to their respective " + "truths", + "SIAP Rate of Track Number Change": "Rate of number of track changes per truth", + "SIAP Longest Track Segment": "Duration of longest associated track segment per truth", + "SIAP ID Completeness": "Fraction of true objects with an assigned ID", + "SIAP ID Correctness": "Fraction of true objects with correct ID assignment", + "SIAP ID Ambiguity": "Fraction of true objects with ambiguous ID assignment" + } From c79c2a5119c251f264c3805fb76cbb10cebffaa0 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 2 May 2024 16:28:53 +0100 Subject: [PATCH 123/170] Update Architecture tutorials to newer versions --- ...2_Information_and_Network_Architectures.py | 599 +++++++++--------- .../architecture/03_Avoiding_Data_Incest.py | 412 +++++++----- stonesoup/architecture/__init__.py | 3 +- 3 files changed, 549 insertions(+), 465 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 101667a25..9eac02ccf 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -2,97 +2,50 @@ # coding: utf-8 """ -========================================= -2 - Information and Network Architectures -========================================= +======================================== +2 - Information vs Network Architectures +======================================== """ - -import random -from datetime import datetime, timedelta -import copy -import numpy as np - - # %% -# Introduction -# ------------ -# Intro to Information and Network Architectures -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# A common example of this is considering posting a letter. You may write some -# information, put it in an envelope, and post it to a friend who receives, opens, and processes -# the information that you sent. You and your friend (and anyone else who your friend passes -# the information to) are nodes in the information architecture. Information architectures only -# concern nodes that open, process and/or fuse data. +# Comparing Information and Network Architectures using ArchitectureGenerators +# ---------------------------------------------------------------------------- # -# Network Architectures consider the underlying framework that enables propagation of information. -# In our example, consider the post-box, sorting office and delivery vans - these are all nodes -# in the network architecture. These nodes do not open and process any of the information that is -# being sent, but they are present in the network and have an effect on the route the information -# takes to reach its destination, and how long it takes to get there. +# In this demo, we intend to show that running a simulation over both an Information +# Architecture, and its underlying Network Architecture, yields the same results. # -# Intro to this Tutorial -# ^^^^^^^^^^^^^^^^^^^^^^ -# Following on from Tutorial 01: Introduction to Architectures in Stone Soup, this tutorial -# provides a more in-depth walk through of generating and simulating information and network -# architectures. +# To build this demonstration, we shall carry out the following steps: # -# We will first create a representation of an information architecture using -# :class:`~.InformationArchitecture` and use it to show how we can simulate detection, -# propagation and fusion of data. +# 1) Build a ground truth, as a basis for the simulation # -# We will then design a network architecture that could uphold the already displayed information -# architecture. To do this we will use :class:`~.NetworkArchitecture`. Additionally, we will use -# the property `information_arch` of :class:`~.NetworkArchitecture` to show that the -# overlying information architecture linked to the created network architecture is identical to -# the information architecture that we started with. +# 2) Build a base sensor model, and a base tracker # -# We start by generating a set of sensors, and a ground truth from which the sensors will make -# measurements. We will make use of these in both information and network architecture examples -# later in the tutorial. - - -# %% -# Set up variables -# ^^^^^^^^^^^^^^^^ - -start_time = datetime.now().replace(microsecond=0) -np.random.seed(1990) -random.seed(1990) - +# 3) Use the ArchitectureGenerator classes to generate 2 pairs of identical architectures +# (1 network, 1 information), where the network architecture is a valid representation of +# the information architecture. +# +# 4) Run simulation over both, and compare results +# +# 5) Remove edges from each of the architectures, rerun and # %% -# Create Sensors +# Module Imports # ^^^^^^^^^^^^^^ -total_no_sensors = 6 - +from datetime import datetime, timedelta from ordered_set import OrderedSet -from stonesoup.types.state import StateVector -from stonesoup.sensor.radar.radar import RadarRotatingBearingRange -from stonesoup.types.angle import Angle +import numpy as np +import random -sensor_set = OrderedSet() -for n in range(0, total_no_sensors): - sensor = RadarRotatingBearingRange( - position_mapping=(0, 2), - noise_covar=np.array([[np.radians(0.5) ** 2, 0], - [0, 1 ** 2]]), - ndim_state=4, - position=np.array([[10], [n*20 - 40]]), - rpm=60, - fov_angle=np.radians(360), - dwell_centre=StateVector([0.0]), - max_range=np.inf, - resolutions={'dwell_centre': Angle(np.radians(30))} - ) - sensor_set.add(sensor) -for sensor in sensor_set: - sensor.timestamp = start_time +# %% +# 1 - Ground Truth +# ---------------- +# We start this tutorial by generating a set of :class:`~.GroundTruthPath`s as a basis for a +# tracking simulation. -# %% -# Create ground truth -# ^^^^^^^^^^^^^^^^^^^ +start_time = datetime.now().replace(microsecond=0) +np.random.seed(2024) +random.seed(2024) from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, \ ConstantVelocity @@ -127,140 +80,152 @@ if j % 2 == 0: ydirection *= -1 - # %% -# Information Architecture Example -# -------------------------------- +# 2 - Base Tracker and Sensor Models +# ---------------------------------- +# We can use the ArchitectureGenerator classes to generate multiple identical architectures. +# These classes take in base tracker and sensor models, which are duplicated and applied to each +# relevant node in the architecture. The base tracker must not have a detector, in order for it +# to be duplicated - the detector will be applied during the architecture generation step. # -# An information architecture represents the points in a network which propagate, open, process, -# and/or fuse data. This type of architecture does not consider the physical network, and hence -# does not represent any nodes whose only functionality is to pass data in between information -# processing nodes. +# Sensor Model +# ^^^^^^^^^^^^ +# The base sensor model's `position` property is used to calculate a location for sensors in +# the architectures that we will generate. As you'll see in later steps, we can either plot +# all sensors at the same location (`base_sensor.position`), or in a specified range around +# the base_sensor's position (`base_sensor.position` +- a specified distance). -# %% -# Information Architecture nodes -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# Firstly, we set up a set of Nodes that will feature in our information architecture. As the set -# of nodes will include FusionNodes. As a prerequisite of building a FusionNode, we must first -# build FusionTrackers and FusionQueues. +from stonesoup.types.state import StateVector +from stonesoup.sensor.radar.radar import RadarRotatingBearingRange +from stonesoup.types.angle import Angle + +# Create base sensor +base_sensor = RadarRotatingBearingRange( + position_mapping=(0, 2), + noise_covar=np.array([[0.25*np.radians(0.5) ** 2, 0], + [0, 0.25*1 ** 2]]), + ndim_state=4, + position=np.array([[10], [10]]), + rpm=60, + fov_angle=np.radians(360), + dwell_centre=StateVector([0.0]), + max_range=np.inf, + resolutions={'dwell_centre': Angle(np.radians(30))} +) +base_sensor.timestamp = start_time # %% -# Build Tracker -# ^^^^^^^^^^^^^ +# Tracker +# ^^^^^^^ +# The base tracker provides a similar concept to the base sensor - it is duplicated and applied +# to each fusion node. In order to duplicate the tracker, it's components must all be compatible +# with being deep-copied. This means that we need to remove the fusion queue and reassign it +# after duplication. + from stonesoup.predictor.kalman import KalmanPredictor from stonesoup.updater.kalman import ExtendedKalmanUpdater from stonesoup.hypothesiser.distance import DistanceHypothesiser from stonesoup.measures import Mahalanobis from stonesoup.dataassociator.neighbour import GNNWith2DAssignment -from stonesoup.deleter.error import CovarianceBasedDeleter +from stonesoup.deleter.time import UpdateTimeStepsDeleter from stonesoup.types.state import GaussianState from stonesoup.initiator.simple import MultiMeasurementInitiator from stonesoup.tracker.simple import MultiTargetTracker -from stonesoup.architecture.edge import FusionQueue +from stonesoup.updater.wrapper import DetectionAndTrackSwitchingUpdater +from stonesoup.updater.chernoff import ChernoffUpdater predictor = KalmanPredictor(transition_model) updater = ExtendedKalmanUpdater(measurement_model=None) hypothesiser = DistanceHypothesiser(predictor, updater, measure=Mahalanobis(), missed_distance=5) data_associator = GNNWith2DAssignment(hypothesiser) -deleter = CovarianceBasedDeleter(covar_trace_thresh=7) +deleter = UpdateTimeStepsDeleter(2) initiator = MultiMeasurementInitiator( prior_state=GaussianState([[0], [0], [0], [0]], np.diag([0, 1, 0, 1])), measurement_model=None, deleter=deleter, data_associator=data_associator, updater=updater, - min_points=2, + min_points=4, ) -tracker = MultiTargetTracker(initiator, deleter, None, data_associator, updater) - - -# %% -# Build Track Tracker -# ^^^^^^^^^^^^^^^^^^^ -# -# The track tracker works by treating tracks as detections, in order to enable fusion between -# tracks and detections together. - -from stonesoup.updater.wrapper import DetectionAndTrackSwitchingUpdater -from stonesoup.updater.chernoff import ChernoffUpdater -from stonesoup.feeder.track import Tracks2GaussianDetectionFeeder - track_updater = ChernoffUpdater(None) detection_updater = ExtendedKalmanUpdater(None) detection_track_updater = DetectionAndTrackSwitchingUpdater(None, detection_updater, track_updater) - -fq = FusionQueue() - -track_tracker = MultiTargetTracker( - initiator, deleter, Tracks2GaussianDetectionFeeder(fq), data_associator, detection_track_updater) - +base_tracker = MultiTargetTracker( + initiator, deleter, None, data_associator, detection_track_updater) # %% -# Build Information Architecture Nodes -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -from stonesoup.architecture.node import SensorNode, FusionNode -node_A = SensorNode(sensor=sensor_set[0], label='SensorNode A') -node_B = SensorNode(sensor=sensor_set[2], label='SensorNode B') - -node_C_tracker = copy.deepcopy(tracker) -node_C_tracker.detector = FusionQueue() -node_C = FusionNode(tracker=node_C_tracker, fusion_queue=node_C_tracker.detector, latency=0, label='FusionNode C') +# 3 - Generate Identical Architectures +# ------------------------------------ +# The NetworkArchitecture class has a property information_arch, which contains the +# information-architecture-representation of the underlying network architecture. This means +# that if we use the NetworkArchitectureGenerator class to generate a pair of identical network +# architectures, we can extract the information architecture from one. +# +# This will provide us with two completely separate architecture classes: a network architecture, +# and an information architecture representation of the same network architecture. This will +# enable us to run simulations on both without interference between the two. -node_D = SensorNode(sensor=sensor_set[1], label='SensorNode D') -node_E = SensorNode(sensor=sensor_set[3], label='SensorNode E') -node_F_tracker = copy.deepcopy(tracker) -node_F_tracker.detector = FusionQueue() -node_F = FusionNode(tracker=node_F_tracker, fusion_queue=node_F_tracker.detector, latency=0) +from stonesoup.architecture.generator import NetworkArchitectureGenerator -node_H = SensorNode(sensor=sensor_set[4]) +gen = NetworkArchitectureGenerator('decentralised', + start_time, + mean_degree=2, + node_ratio=[3, 1, 2], + base_tracker=base_tracker, + base_sensor=base_sensor, + sensor_max_distance=(30, 30), + n_archs=4) +id_net_archs = gen.generate() -node_G = FusionNode(tracker=track_tracker, fusion_queue=fq, latency=0) +# Network and Information arch pair +network_arch = id_net_archs[0] +information_arch = id_net_archs[1].information_arch +network_arch.plot() # %% -# Build Information Architecture Edges -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -from stonesoup.architecture import InformationArchitecture -from stonesoup.architecture.edge import Edge, Edges - -edges=Edges([Edge((node_A, node_C), edge_latency=0.5), - Edge((node_B, node_C)), - Edge((node_D, node_F)), - Edge((node_E, node_F)), - Edge((node_C, node_G), edge_latency=0), - Edge((node_F, node_G), edge_latency=0), - Edge((node_H, node_G)), - ]) +information_arch.plot() +# %% +# The two plots above display a network architecture, and corresponding information architecture, +# respectively. Grey nodes in the network architecture represent repeater nodes - these have the +# sole purpose of passing data from one node to another. Comparing the two graphs, while ignoring +# the repeater nodes, should reveal that the two plots are both representations of the same +# system. # %% -# Build and plot Information Architecture -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# 4 - Tracking Simulations +# ------------------------ +# With two identical architectures, we can now run a simulation over both, in an attempt to +# produce identical results. # -# sphinx_gallery_thumbnail_path = '_static/sphinx_gallery/ArchTutorial_2.png' -# -# Here we can see the Information Architecture that we have built. We can next run a simulation -# over this architecture and plot the tracking results. -information_architecture = InformationArchitecture(edges, current_time=start_time) -information_architecture +# Run Network Architecture Simulation +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Run the simulation over the network architecture. We then extract some extra information from +# the architecture to add to the plot - location of sensors, and detections. -# %% -# Simulate measuring and propagating data over the network -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ for time in timesteps: - information_architecture.measure(truths, noise=True) - information_architecture.propagate(time_increment=1) + network_arch.measure(truths, noise=True) + network_arch.propagate(time_increment=1) # %% +na_sensors = [] +na_dets = set() +for sn in network_arch.sensor_nodes: + na_sensors.append(sn.sensor) + for timestep in sn.data_held['created'].keys(): + for datapiece in sn.data_held['created'][timestep]: + na_dets.add(datapiece.data) + +# %% +# Plot +# ^^^^ from stonesoup.plotter import Plotterly @@ -271,180 +236,232 @@ def reduce_tracks(tracks): for track in tracks} -# %% -# Plot Tracks at the Fusion Nodes -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - plotter = Plotterly() - plotter.plot_ground_truths(truths, [0, 2]) -plotter.plot_tracks(reduce_tracks(node_C.tracks), [0, 2], track_label=node_C.label, - line=dict(color='#00FF00'), uncertainty=True) -plotter.plot_tracks(reduce_tracks(node_F.tracks), [0, 2], track_label=node_F.label, - line=dict(color='#FF0000'), uncertainty=True) -plotter.plot_tracks(reduce_tracks(node_G.tracks), [0, 2], track_label=node_G.label, - line=dict(color='#0000FF'), uncertainty=True) -plotter.plot_sensors(sensor_set) +for node in network_arch.fusion_nodes: + if True: + hexcol = ["#"+''.join([random.choice('ABCDEF0123456789') for i in range(6)])] + plotter.plot_tracks(reduce_tracks(node.tracks), + [0, 2], + track_label=str(node.label), + line=dict(color=hexcol[0]), + uncertainty=True) +plotter.plot_sensors(na_sensors) +plotter.plot_measurements(na_dets, [0, 2]) plotter.fig +# %% +# Run Information Architecture Simulation +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Run the simulation over the information architecture. As before, we extract some extra +# information from the architecture to add to the plot - location of sensors, and detections. + + +for time in timesteps: + information_arch.measure(truths, noise=True) + information_arch.propagate(time_increment=1) # %% -# Network Architecture Example -# ---------------------------- -# A network architecture represents the full physical network behind an information architecture. For an analogy, we -# might compare an edge in the information architecture to the connection between a sender and recipient of an email. -# Much of the time, we only care about the information architecture and not the actual mechanisms behind delivery of the -# email, which is similar in nature to the network architecture. -# Some nodes have the sole purpose of receiving and re-transmitting data on to other nodes in the network. We call -# these :class:`~.RepeaterNode`s. Additionally, any :class:`~.Node` present in the :class:`~.InformationArchitecture` -# must also be modelled in the :class:`~.NetworkArchitecture`. +ia_sensors = [] +ia_dets = set() +for sn in information_arch.sensor_nodes: + ia_sensors.append(sn.sensor) + for timestep in sn.data_held['created'].keys(): + for datapiece in sn.data_held['created'][timestep]: + ia_dets.add(datapiece.data) + +# %% +plotter = Plotterly() +plotter.plot_ground_truths(truths, [0, 2]) +for node in information_arch.fusion_nodes: + if True: + hexcol = ["#"+''.join([random.choice('ABCDEF0123456789') for i in range(6)])] + plotter.plot_tracks(reduce_tracks(node.tracks), [0, 2], track_label=str(node.label), line=dict(color=hexcol[0]), uncertainty=True) +plotter.plot_sensors(ia_sensors) +plotter.plot_measurements(ia_dets, [0, 2]) +plotter.fig # %% -# Network Architecture Nodes -# ^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Comparing Tracks from each Architecture # -# In this example, we will add six new :class:`~.RepeaterNode`s to the existing structure to create our corresponding -# :class:`~.NetworkArchitecture`. +# The information architecture we have studied is hierarchical, and while the network +# architecture isn't strictly a hierarchical graph, it does have one central node receiving all +# information. The central node is Fusion Node 1. The code below plots SIAP metrics for the +# tracks maintained at Fusion Node 1 in both architecures. Some variation between the two is +# expected due to the randomness of the measurements, but we aim to show that the results from +# both architectures are near identical. -from stonesoup.architecture.node import RepeaterNode +top_node = network_arch.top_level_nodes.pop() -repeaternode1 = RepeaterNode(label='RepeaterNode 1') -repeaternode2 = RepeaterNode(label='RepeaterNode 2') -repeaternode3 = RepeaterNode(label='RepeaterNode 3') -repeaternode4 = RepeaterNode(label='RepeaterNode 4') -repeaternode5 = RepeaterNode(label='RepeaterNode 5') -repeaternode6 = RepeaterNode(label='RepeaterNode 6') +# %% +from stonesoup.metricgenerator.tracktotruthmetrics import SIAPMetrics +from stonesoup.measures import Euclidean +from stonesoup.dataassociator.tracktotrack import TrackToTruth +from stonesoup.metricgenerator.manager import MultiManager -node_A = SensorNode(sensor=sensor_set[0], label='SensorNode A') -node_B = SensorNode(sensor=sensor_set[2], label='SensorNode B') +network_siap = SIAPMetrics(position_measure=Euclidean((0, 2)), + velocity_measure=Euclidean((1, 3)), + generator_name='network_siap', + tracks_key='network_tracks', + truths_key='truths' + ) -node_C_tracker = copy.deepcopy(tracker) -node_C_tracker.detector = FusionQueue() -node_C = FusionNode(tracker=node_C_tracker, fusion_queue=node_C_tracker.detector, latency=0, - label='FusionNode C') +associator = TrackToTruth(association_threshold=30) -node_D = SensorNode(sensor=sensor_set[1], label='SensorNode D') -node_E = SensorNode(sensor=sensor_set[3], label='SensorNode E') -node_F_tracker = copy.deepcopy(tracker) -node_F_tracker.detector = FusionQueue() -node_F = FusionNode(tracker=node_F_tracker, fusion_queue=node_F_tracker.detector, latency=0) +# %% +network_metric_manager = MultiManager([network_siap], associator) +network_metric_manager.add_data({'network_tracks': top_node.tracks, + 'truths': truths}, overwrite=False) +network_metrics = network_metric_manager.generate_metrics() -node_H = SensorNode(sensor=sensor_set[4]) +# %% +network_siap_metrics = network_metrics['network_siap'] +network_siap_averages = {network_siap_metrics.get(metric) for metric in network_siap_metrics + if metric.startswith("SIAP") and not metric.endswith(" at times")} -fq = FusionQueue() -track_tracker = MultiTargetTracker( - initiator, deleter, Tracks2GaussianDetectionFeeder(fq), - data_associator, detection_track_updater) +# %% +top_node = information_arch.top_level_nodes.pop() -node_G = FusionNode(tracker=track_tracker, fusion_queue=fq, latency=0) +# %% +information_siap = SIAPMetrics(position_measure=Euclidean((0, 2)), + velocity_measure=Euclidean((1, 3)), + generator_name='information_siap', + tracks_key='information_tracks', + truths_key='truths' + ) +associator = TrackToTruth(association_threshold=30) # %% -# Network Architecture Edges -# ^^^^^^^^^^^^^^^^^^^^^^^^^^ +information_metric_manager = MultiManager([information_siap], associator) +information_metric_manager.add_data({'information_tracks': top_node.tracks, + 'truths': truths}, overwrite=False) +information_metrics = information_metric_manager.generate_metrics() -edges = Edges([Edge((node_A, repeaternode1), edge_latency=0.5), - Edge((repeaternode1, node_C), edge_latency=0.5), - Edge((node_B, repeaternode3)), - Edge((repeaternode3, node_C)), - Edge((node_A, repeaternode2), edge_latency=0.5), - Edge((repeaternode2, node_C)), - Edge((repeaternode1, repeaternode2)), - Edge((node_D, repeaternode4)), - Edge((repeaternode4, node_F)), - Edge((node_E, repeaternode5)), - Edge((repeaternode5, node_F)), - Edge((node_C, node_G), edge_latency=0), - Edge((node_F, node_G), edge_latency=0), - Edge((node_H, repeaternode6)), - Edge((repeaternode6, node_G)) - ]) +# %% +information_siap_metrics = information_metrics['information_siap'] +information_siap_averages = {information_siap_metrics.get(metric) for + metric in information_siap_metrics if + metric.startswith("SIAP") and not metric.endswith(" at times")} +# %% +from stonesoup.metricgenerator.metrictables import SIAPDiffTableGenerator +SIAPDiffTableGenerator([network_siap_averages, information_siap_averages]).compute_metric() # %% -# Network Architecture Functionality -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# A network architecture provides a representation of all nodes in a network - the corresponding -# information architecture is made up of a subset of these nodes. -# -# To aid in modelling this in Stone Soup, the NetworkArchitecture class has a property -# `information_arch`: an InformationArchitecture object representing the information -# architecture that is underpinned by the network architecture. This means that a -# NetworkArchitecture object requires two Edge lists: one set of edges representing the -# network architecture, and another representing links in the information architecture. To -# ease the setup of these edge lists, there are multiple options for how to instantiate a -# :class:`~.NetworkArchitecture` -# -# - Firstly, providing the :class:`~.NetworkArchitecture` with an `edge_list` for the network -# architecture (an Edges object), and a pre-fabricated InformationArchitecture object, which must -# be provided as property `information_arch`. +# 5 - Remove edges from each architecture and re-run +# -------------------------------------------------- +# In this section, we take an identical copy of each of the architectures above, and remove an +# edge. We aim to show the following 2 points: # -# - Secondly, by providing the NetworkArchitecture with two `edge_list` values: one for the network -# architecture and one for the information architecture. +# 1) It is possible to remove certain edges from a network architecture without effecting the +# performance of the network. # -# - Thirdly, by providing just a set of edges for the network architecture. In this case, -# the NetworkArchitecture class will infer which nodes in the network architecture are also -# part of the information architecture, and form an edge set by calculating the fastest routes -# (the lowest latency) between each set of nodes in the architecture. Warning: this method is for -# ease of use, and may not represent the information architecture you are designing - it is -# best to check by plotting the generated information architecture. +# 2) Removing an edge from an information architecture will likely have an effect on performance. # +# First, we must set up the two architectures, and remove an edge from each. In the network +# architecture, there are multiple routes between some pairs of nodes. This redundency increases +# the resiliance of the network when an edge, or node, is taken out of action. In this example, +# we remove edges connecting repeater node r3, in turn, disabling a route from sensor node s0 +# to fusion node f0. As another route from s0 to f0 exists (via repeater node r4), the +# performance of the network should not be effected (assuming unlimited bandwidth). +# %% +# Network and Information arch pair +network_arch_rm = id_net_archs[2] +information_arch_rm = id_net_archs[3].information_arch # %% -# Instantiate Network Architecture -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +rm = [] +for edge in network_arch_rm.edges: + if 'r3' in [node.label for node in edge.nodes]: + rm.append(edge) -from stonesoup.architecture import NetworkArchitecture -network_architecture = NetworkArchitecture(edges=edges, current_time=start_time) +for edge in rm: + network_arch_rm.edges.remove(edge) +# %% +network_arch_rm.plot() # %% -# Simulate measurement and propagation across the Network Architecture -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Now we remove an edge from the information architecture. You could choose pretty much any +# edge here, but removing the edge between sf0 and f1 is likely to cause the greatest destruction +# (in the interest of the reader). Removing this edge creates a disconnected graph. The Stone +# Soup architecture module can deal with this with no issues, but for this example we will now +# only consider the connected subgraph containing node f1. -for time in timesteps: - network_architecture.measure(truths, noise=True) - network_architecture.propagate(time_increment=1) +rm = [] +for edge in information_arch_rm.edges: + if 'sf0' in [node.label for node in edge.nodes] and 'f1' in [node.label for node in edge.nodes]: + rm.append(edge) + +for edge in rm: + information_arch_rm.edges.remove(edge) # %% -# Plot the Network Architecture -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# The plot below displays the Network Architecture we have built. This includes all Nodes, -# including those that do not feature in the Information Architecture (the repeater nodes). +information_arch_rm.plot() + +# %% +# We now run the simulation for both architectures and calculate the same SIAP metrics as we +# did before for the original architectures. + -network_architecture +for time in timesteps: + network_arch_rm.measure(truths, noise=True) + network_arch_rm.propagate(time_increment=1) + information_arch_rm.measure(truths, noise=True) + information_arch_rm.propagate(time_increment=1) # %% -# Plot the Network Architecture's Information Architecture -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# Next we plot the information architecture that is underpinned by the network architecture. The -# nodes of the information architecture are a subset of the nodes from the network architecture. -# An edge in the Information Architecture could be equivalent -# to a physical edge between two nodes in the Network Architecture, but it could also be a -# representation of multiple edges and nodes that a data piece would be transferred through in -# order to pass from a sender to a recipient node. -# Observing the plot of the information architecture, we can see that it is identical to the -# information architecture that we started this tutorial with. +top_node = [node for node in network_arch_rm.all_nodes if node.label == 'f1'][0] + +network_rm_siap = SIAPMetrics(position_measure=Euclidean((0, 2)), + velocity_measure=Euclidean((1, 3)), + generator_name='network_rm_siap', + tracks_key='network_rm_tracks', + truths_key='truths' + ) + +network_rm_metric_manager = MultiManager([network_rm_siap], associator) +network_rm_metric_manager.add_data({'network_rm_tracks': top_node.tracks, + 'truths': truths}, overwrite=False) +network_rm_metrics = network_rm_metric_manager.generate_metrics() -network_architecture.information_arch +network_rm_siap_metrics = network_rm_metrics['network_rm_siap'] +network_rm_siap_averages = {network_rm_siap_metrics.get(metric) for + metric in network_rm_siap_metrics + if metric.startswith("SIAP") and not metric.endswith(" at times")} # %% -# Plot Tracks at the Fusion Nodes (Network Architecture) -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +top_node = [node for node in information_arch_rm.all_nodes if node.label == 'f1'][0] + +information_rm_siap = SIAPMetrics(position_measure=Euclidean((0, 2)), + velocity_measure=Euclidean((1, 3)), + generator_name='information_rm_siap', + tracks_key='information_rm_tracks', + truths_key='truths' + ) + +information_rm_metric_manager = MultiManager([information_rm_siap, + ], associator) # associator for generating SIAP metrics +information_rm_metric_manager.add_data({'information_rm_tracks': top_node.tracks, + 'truths': truths}, overwrite=False) +information_rm_metrics = information_rm_metric_manager.generate_metrics() + +information_rm_siap_metrics = information_rm_metrics['information_rm_siap'] +information_rm_siap_averages = {information_rm_siap_metrics.get(metric) for + metric in information_rm_siap_metrics + if metric.startswith("SIAP") and not metric.endswith(" at times")} -plotter = Plotterly() -plotter.plot_ground_truths(truths, [0, 2]) -plotter.plot_tracks(reduce_tracks(node_C.tracks), [0, 2], track_label=node_C.label, - line=dict(color='#00FF00'), uncertainty=True) -plotter.plot_tracks(reduce_tracks(node_F.tracks), [0, 2], track_label=node_F.label, - line=dict(color='#FF0000'), uncertainty=True) -plotter.plot_tracks(reduce_tracks(node_G.tracks), [0, 2], track_label=node_G.label, - line=dict(color='#0000FF'), uncertainty=True) -plotter.plot_sensors(sensor_set) -plotter.fig +# %% +# Plotting the metrics for the two original architectures, and the metrics for the copies with +# edges removed, should display the result we predicted at the start of this section. + +# %% +SIAPDiffTableGenerator([network_siap_averages, + information_siap_averages, + network_rm_siap_averages, + information_rm_siap_averages], + ['Network', 'Info', 'Network RM', 'Info RM']).compute_metric() diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 3cabd2b15..95b0375e3 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -2,9 +2,9 @@ # coding: utf-8 """ -======================== -3 - Avoiding Data Incest -======================== +========================== +# 3 - Avoiding Data Incest +========================== """ import random @@ -12,7 +12,6 @@ import numpy as np from datetime import datetime, timedelta - # %% # Introduction # ------------ @@ -20,64 +19,104 @@ # can occur in a poorly designed network. # # We design two architectures: a centralised (non-hierarchical) architecture, and a hierarchical -# alternative, and look to compare the fused results at the central node. We expect to show that -# non-hierarchical architecture will have poor tracking performance in comparison to the -# hierarchical alternative. +# alternative, and look to compare the fused results at the central node. +# +# ## Scenario generation +# # -# Scenario generation -# ------------------- start_time = datetime.now().replace(microsecond=0) np.random.seed(1990) random.seed(1990) # %% -# "Good" and "Bad" Sensors -# ^^^^^^^^^^^^^^^^^^^^^^^^ +# Sensors +# ^^^^^^^ +# +# We build two sensors to be assigned to the two sensor nodes # -# We build a "good" sensor with low measurement noise (high measurement accuracy). from stonesoup.types.state import StateVector from stonesoup.sensor.radar.radar import RadarRotatingBearingRange from stonesoup.types.angle import Angle +from stonesoup.models.clutter import ClutterModel + +# sf1 = 0.85 + +# sensor1 = RadarRotatingBearingRange( +# position_mapping=[0, 2], +# noise_covar=np.array([[sf1 * np.radians(0.5) ** 2, 0], +# [0, sf1 * 1 ** 2]]), +# ndim_state=4, +# position=np.array([[10], [20 - 40]]), +# rpm=60, +# fov_angle=np.radians(360), +# dwell_centre=StateVector([0.0]), +# max_range=np.inf, +# resolutions={'dwell_centre': Angle(np.radians(30))}, +# clutter_model=ClutterModel(clutter_rate=15, dist_params=((-100, 100), (-50, 60))) +# ) +# sensor1.timestamp = start_time + +# %% + +# sf2 = 0.85 +# sensor2 = RadarRotatingBearingRange( +# position_mapping=[0, 2], +# noise_covar=np.array([[sf2 * np.radians(0.5) ** 2, 0], +# [0, sf2 * 1 ** 2]]), +# ndim_state=4, +# position=np.array([[10], [3*20 - 40]]), +# rpm=60, +# fov_angle=np.radians(360), +# dwell_centre=StateVector([0.0]), +# max_range=np.inf, +# resolutions={'dwell_centre': Angle(np.radians(30))}, +# clutter_model=ClutterModel(clutter_rate=5, dist_params=((-100, 100), (-50, 60))) +# ) + +# sensor2.timestamp = start_time + +# %% +from stonesoup.models.measurement.linear import LinearGaussian +from stonesoup.types.state import CovarianceMatrix + +mm = LinearGaussian(ndim_state=4, + mapping=[0, 2], + noise_covar=CovarianceMatrix(np.diag([0.5, 0.5])), + seed=6) -good_sensor = RadarRotatingBearingRange( - position_mapping=(0, 2), - noise_covar=np.array([[np.radians(0.5) ** 2, 0], - [0, 1 ** 2]]), - ndim_state=4, - position=np.array([[10], [20 - 40]]), - rpm=60, - fov_angle=np.radians(360), - dwell_centre=StateVector([0.0]), - max_range=np.inf, - resolutions={'dwell_centre': Angle(np.radians(30))} - ) - -good_sensor.timestamp = start_time - -# %% -# We then build a third "bad" sensor with high measurement noise (low measurement accuracy). -# This will enable us to design an architecture and observe how the "bad" measurements are -# propagated and fused with the "good" measurements. The bad sensor has its noise covariance -# scaled by a factor of `sf` over the noise of the good sensors. - -sf = 2 -bad_sensor = RadarRotatingBearingRange( - position_mapping=(0, 2), - noise_covar=np.array([[sf * np.radians(0.5) ** 2, 0], - [0, sf * 1 ** 2]]), - ndim_state=4, - position=np.array([[10], [3*20 - 40]]), - rpm=60, - fov_angle=np.radians(360), - dwell_centre=StateVector([0.0]), - max_range=np.inf, - resolutions={'dwell_centre': Angle(np.radians(30))} - ) - -bad_sensor.timestamp = start_time -all_sensors = {good_sensor, bad_sensor} +mm2 = LinearGaussian(ndim_state=4, + mapping=[0, 2], + noise_covar=CovarianceMatrix(np.diag([0.5, 0.5])), + seed=6) + +# %% +from stonesoup.sensor.sensor import SimpleSensor +from stonesoup.models.measurement.base import MeasurementModel +from stonesoup.base import Property + + +class DummySensor(SimpleSensor): + measurement_model: MeasurementModel = Property(doc="TODO") + + def is_detectable(self, state): + return True + + def is_clutter_detectable(self, state): + return True + + +sensor1 = DummySensor(measurement_model=mm, + position=np.array([[10], [-20]]), + clutter_model=ClutterModel(clutter_rate=5, + dist_params=((-100, 100), (-50, 60)), seed=6)) +sensor1.clutter_model.distribution = sensor1.clutter_model.random_state.uniform +sensor2 = DummySensor(measurement_model=mm2, + position=np.array([[10], [20]]), + clutter_model=ClutterModel(clutter_rate=5, + dist_params=((-100, 100), (-50, 60)), seed=6)) +sensor2.clutter_model.distribution = sensor2.clutter_model.random_state.uniform # %% # Ground Truth @@ -94,7 +133,7 @@ yps = range(0, 100, 10) # y value for prior state truths = OrderedSet() -ntruths = 1 # number of ground truths in simulation +ntruths = 3 # number of ground truths in simulation time_max = 60 # timestamps the simulation is observed over timesteps = [start_time + timedelta(seconds=k) for k in range(time_max)] @@ -120,8 +159,8 @@ # %% # Build Tracker # ^^^^^^^^^^^^^ -# # We use the same configuration of trackers and track-trackers as we did in the previous tutorial. +# from stonesoup.predictor.kalman import KalmanPredictor from stonesoup.updater.kalman import ExtendedKalmanUpdater @@ -134,25 +173,27 @@ from stonesoup.tracker.simple import MultiTargetTracker from stonesoup.architecture.edge import FusionQueue +prior = GaussianState([[0], [1], [0], [1]], np.diag([1, 1, 1, 1])) predictor = KalmanPredictor(transition_model) updater = ExtendedKalmanUpdater(measurement_model=None) hypothesiser = DistanceHypothesiser(predictor, updater, measure=Mahalanobis(), missed_distance=5) data_associator = GNNWith2DAssignment(hypothesiser) deleter = CovarianceBasedDeleter(covar_trace_thresh=3) initiator = MultiMeasurementInitiator( - prior_state=GaussianState([[0], [0], [0], [0]], np.diag([0, 1, 0, 1])), + prior_state=prior, measurement_model=None, deleter=deleter, data_associator=data_associator, updater=updater, - min_points=2, - ) + min_points=5, +) tracker = MultiTargetTracker(initiator, deleter, None, data_associator, updater) # %% # Track Tracker # ^^^^^^^^^^^^^ +# from stonesoup.updater.wrapper import DetectionAndTrackSwitchingUpdater from stonesoup.updater.chernoff import ChernoffUpdater @@ -162,66 +203,70 @@ detection_updater = ExtendedKalmanUpdater(None) detection_track_updater = DetectionAndTrackSwitchingUpdater(None, detection_updater, track_updater) - fq = FusionQueue() track_tracker = MultiTargetTracker( - initiator, deleter, Tracks2GaussianDetectionFeeder(fq), data_associator, detection_track_updater) + initiator, deleter, None, data_associator, detection_track_updater) # %% # Non-Hierarchical Architecture # ----------------------------- -# -# We start by constructing the non-hierarchical, centralised architecture. This architecture -# consists of 3 nodes: one sensor node, which sends data to a fusion node, and a sensor fusion -# node. This sensor fusion performs two operations: -# a) recording its own data from its sensor, and -# b) fusing its own data with the data received from the sensor node. -# These detections and fused outputs are passed on to the same fusion node mentioned before. -# The fusion node receives data from both the sensor node and sensor fusion node. The data -# passed from the sensor fusion node will contain information that was calculated by processing -# the detections from the sensor node. This means that the information created by the sensor node -# is arriving at the fusion node via two routes, but is being treated as if it is from separate -# sources. This can cause a bias towards the data generated at the sensor node, rather than that -# created at the sensor fusion node. This is an example of data incest. +# We start by constructing the non-hierarchical, centralised architecture. # # Nodes # ^^^^^ +# from stonesoup.architecture.node import SensorNode, FusionNode, SensorFusionNode -node_B1_tracker = copy.deepcopy(tracker) -node_B1_tracker.detector = FusionQueue() +sensornode1 = SensorNode(sensor=copy.deepcopy(sensor1), label='Sensor Node 1') +sensornode1.sensor.clutter_model.distribution = \ + sensornode1.sensor.clutter_model.random_state.uniform -node_A1 = SensorNode(sensor=bad_sensor, - label='Bad\nSensorNode') -node_B1 = SensorFusionNode(sensor=good_sensor, - label='Good\nSensorFusionNode', - tracker=node_B1_tracker, - fusion_queue=node_B1_tracker.detector) -node_C1 = FusionNode(tracker=track_tracker, - fusion_queue=fq, - latency=0, - label='Fusion Node') +sensornode2 = SensorNode(sensor=copy.deepcopy(sensor2), label='Sensor Node 2') +sensornode2.sensor.clutter_model.distribution = \ + sensornode2.sensor.clutter_model.random_state.uniform + +f1_tracker = copy.deepcopy(track_tracker) +f1_fq = FusionQueue() +f1_tracker.detector = Tracks2GaussianDetectionFeeder(f1_fq) +fusion_node1 = FusionNode(tracker=f1_tracker, fusion_queue=f1_fq, label='Fusion Node 1') + +f2_tracker = copy.deepcopy(track_tracker) +f2_fq = FusionQueue() +f2_tracker.detector = Tracks2GaussianDetectionFeeder(f2_fq) +fusion_node2 = FusionNode(tracker=f2_tracker, fusion_queue=f2_fq, label='Fusion Node 2') # %% # Edges # ^^^^^ -# # Here we define the set of edges for the non-hierarchical (NH) architecture. from stonesoup.architecture import InformationArchitecture from stonesoup.architecture.edge import Edge, Edges -NH_edges = Edges([Edge((node_A1, node_B1), edge_latency=1), - Edge((node_B1, node_C1), edge_latency=0), - Edge((node_A1, node_C1), edge_latency=0)]) +NH_edges = Edges([Edge((sensornode1, fusion_node1), edge_latency=0), + Edge((sensornode1, fusion_node2), edge_latency=0), + Edge((sensornode2, fusion_node2), edge_latency=0), + Edge((fusion_node2, fusion_node1), edge_latency=0)]) # %% # Create the Non-Hierarchical Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# The cell below should create and plot the architecture we have built. This architecture is at +# risk of data incest, due to the fact that information from sensor node 1 could reach fusion +# node 1 via two routes, while appearing to not be from the same source: +# +# Route 1: Sensor Node 1 (S1) passes its information straight to Fusion Node 1 (F1) +# +# Route 2: S1 also passes its information to Fusion Node 2 (F2). Here it is fused with +# information from Sensor Node 2 (S2). This resulting information is then passed to Fusion Node 1. +# +# Ultimately, F1 is recieving information from S1, and information from F2 which is based on the +# same information from S1. This can cause a bias towards the information created at S1. In this +# example, we would expect to see overconfidence in the form of unrealistically small uncertainty +# of the output tracks. -# sphinx_gallery_thumbnail_path = '_static/sphinx_gallery/ArchTutorial_3.png' NH_architecture = InformationArchitecture(NH_edges, current_time=start_time, use_arrival_time=True) NH_architecture.plot(plot_style='hierarchical') # Style similar to hierarchical @@ -230,6 +275,7 @@ # %% # Run the Non-Hierarchical Simulation # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# for time in timesteps: NH_architecture.measure(truths, noise=True) @@ -238,23 +284,20 @@ # %% # Extract all detections that arrived at Non-Hierarchical Node C # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# -from stonesoup.types.detection import TrueDetection - -NH_detections = set() -for timestep in node_C1.data_held['unfused']: - for datapiece in node_C1.data_held['unfused'][timestep]: - if isinstance(datapiece.data, TrueDetection): - NH_detections.add(datapiece.data) +NH_sensors = [] +NH_dets = set() +for sn in NH_architecture.sensor_nodes: + NH_sensors.append(sn.sensor) + for timestep in sn.data_held['created'].keys(): + for datapiece in sn.data_held['created'][timestep]: + NH_dets.add(datapiece.data) # %% # Plot the tracks stored at Non-Hierarchical Node C # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# Inspecting the plot below shows multiple tracks. The extra delay incurred by passing data along -# multiple edges is causing enough of a delay that the multi-target tracker is identifying the -# delayed measurements as a completely different target. This has a negative impact in tracking -# performance and metrics. Later in this example, a plot of the hierarchical architecture tracks -# should show this issue is resolved. +# from stonesoup.plotter import Plotterly @@ -267,55 +310,60 @@ def reduce_tracks(tracks): plotter = Plotterly() plotter.plot_ground_truths(truths, [0, 2]) -plotter.plot_tracks(reduce_tracks(node_C1.tracks), [0, 2], track_label="Node C1", - line=dict(color='#00FF00'), uncertainty=True) -plotter.plot_sensors(all_sensors) -plotter.plot_measurements(NH_detections, [0, 2]) +for node in NH_architecture.fusion_nodes: + hexcol = ["#" + ''.join([random.choice('ABCDEF0123456789') for i in range(6)])] + plotter.plot_tracks(reduce_tracks(node.tracks), [0, 2], track_label=str(node.label), + line=dict(color=hexcol[0]), uncertainty=True) +plotter.plot_sensors(NH_sensors) +plotter.plot_measurements(NH_dets, [0, 2]) plotter.fig # %% # Hierarchical Architecture # ------------------------- -# -# We now create an alternative architecture. We recreate the same set of nodes as before. We use -# the same set of edges as before, but remove the edge from the sensor node to the fusion node. -# This change stops the same information that is generated at the sensor node, from reaching the -# fusion node in two forms. This avoids the possibility of data incest from occurring. -# +# We now create an alternative architecture. We recreate the same set of nodes as before, but +# with a new edge set, which is a subset of the edge set used in the non-hierarchical +# architecture. + +# %% # Regenerate Nodes identical to those in the non-hierarchical example # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# -from stonesoup.architecture.node import SensorNode, FusionNode -fq2 = FusionQueue() +from stonesoup.architecture.node import SensorNode, FusionNode, SensorFusionNode -track_tracker2 = MultiTargetTracker( - initiator, deleter, Tracks2GaussianDetectionFeeder(fq2), data_associator, - detection_track_updater) +sensornode1B = SensorNode(sensor=copy.deepcopy(sensor1), label='Sensor Node 1') +sensornode1B.sensor.clutter_model.distribution = \ + sensornode1B.sensor.clutter_model.random_state.uniform -node_B2_tracker = copy.deepcopy(tracker) -node_B2_tracker.detector = FusionQueue() +sensornode2B = SensorNode(sensor=copy.deepcopy(sensor2), label='Sensor Node 2') +sensornode2B.sensor.clutter_model.distribution = \ + sensornode2B.sensor.clutter_model.random_state.uniform -node_A2 = SensorNode(sensor=bad_sensor, - label='Bad\nSensorNode') -node_B2 = SensorFusionNode(sensor=good_sensor, - label='Good\nSensorFusionNode', - tracker=node_B2_tracker, - fusion_queue=node_B2_tracker.detector) -node_C2 = FusionNode(tracker=track_tracker2, - fusion_queue=fq2, - latency=0, - label='Fusion Node') +f1_trackerB = copy.deepcopy(track_tracker) +f1_fqB = FusionQueue() +f1_trackerB.detector = Tracks2GaussianDetectionFeeder(f1_fqB) +fusion_node1B = FusionNode(tracker=f1_trackerB, fusion_queue=f1_fqB, label='Fusion Node 1') + +f2_trackerB = copy.deepcopy(track_tracker) +f2_fqB = FusionQueue() +f2_trackerB.detector = Tracks2GaussianDetectionFeeder(f2_fqB) +fusion_node2B = FusionNode(tracker=f2_trackerB, fusion_queue=f2_fqB, label='Fusion Node 2') # %% # Create Edges forming a Hierarchical Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# -H_edges = Edges([Edge((node_A2, node_C2), edge_latency=0), - Edge((node_B2, node_C2), edge_latency=0)]) +H_edges = Edges([Edge((sensornode1B, fusion_node1B), edge_latency=0), + Edge((sensornode2B, fusion_node2B), edge_latency=0), + Edge((fusion_node2B, fusion_node1B), edge_latency=0)]) # %% # Create the Hierarchical Architecture -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# The only difference between the two architectures is the removal of the edge from S1 to F2. +# This change removes the second route for information to travel from S1 to F1. H_architecture = InformationArchitecture(H_edges, current_time=start_time, use_arrival_time=True) H_architecture @@ -323,6 +371,7 @@ def reduce_tracks(tracks): # %% # Run the Hierarchical Simulation # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# for time in timesteps: H_architecture.measure(truths, noise=True) @@ -331,26 +380,29 @@ def reduce_tracks(tracks): # %% # Extract all detections that arrived at Hierarchical Node C # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# -H_detections = set() -for timestep in node_C2.data_held['unfused']: - for datapiece in node_C2.data_held['unfused'][timestep]: - if isinstance(datapiece.data, TrueDetection): - H_detections.add(datapiece.data) +H_sensors = [] +H_dets = set() +for sn in H_architecture.sensor_nodes: + H_sensors.append(sn.sensor) + for timestep in sn.data_held['created'].keys(): + for datapiece in sn.data_held['created'][timestep]: + H_dets.add(datapiece.data) # %% # Plot the tracks stored at Hierarchical Node C # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# The plot of tracks from the hierarchical architecture should show that the issue with multiple -# tracks being initiated has been resolved. This is due to the removal of the edge causing extra -# delay. +# plotter = Plotterly() plotter.plot_ground_truths(truths, [0, 2]) -plotter.plot_tracks(reduce_tracks(node_C2.tracks), [0, 2], track_label="Node C2", - line=dict(color='#00FF00'), uncertainty=True) -plotter.plot_sensors(all_sensors) -plotter.plot_measurements(H_detections, [0,2]) +for node in H_architecture.fusion_nodes: + hexcol = ["#" + ''.join([random.choice('ABCDEF0123456789') for i in range(6)])] + plotter.plot_tracks(reduce_tracks(node.tracks), [0, 2], track_label=str(node.label), + line=dict(color=hexcol[0]), uncertainty=True) +plotter.plot_sensors(H_sensors) +plotter.plot_measurements(H_dets, [0, 2]) plotter.fig # %% @@ -360,8 +412,11 @@ def reduce_tracks(tracks): # the original centralised architecture. We will now calculate and plot some metrics to give an # insight into the differences. # + +# %% # Calculate SIAP metrics for Centralised Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# from stonesoup.metricgenerator.tracktotruthmetrics import SIAPMetrics from stonesoup.measures import Euclidean @@ -377,26 +432,23 @@ def reduce_tracks(tracks): associator = TrackToTruth(association_threshold=30) - # %% - NH_metric_manager = MultiManager([NH_siap_EKF_truth, ], associator) # associator for generating SIAP metrics -NH_metric_manager.add_data({'NH_EKF_tracks': node_C1.tracks, +NH_metric_manager.add_data({'NH_EKF_tracks': fusion_node1.tracks, 'truths': truths, - 'NH_detections': NH_detections}, overwrite=False) + 'NH_detections': NH_dets}, overwrite=False) NH_metrics = NH_metric_manager.generate_metrics() - # %% - NH_siap_metrics = NH_metrics['NH_SIAP_EKF-truth'] NH_siap_averages_EKF = {NH_siap_metrics.get(metric) for metric in NH_siap_metrics if metric.startswith("SIAP") and not metric.endswith(" at times")} -# %% +#%% # Calculate Metrics for Hierarchical Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# H_siap_EKF_truth = SIAPMetrics(position_measure=Euclidean((0, 2)), velocity_measure=Euclidean((1, 3)), @@ -407,26 +459,24 @@ def reduce_tracks(tracks): associator = TrackToTruth(association_threshold=30) - # %% - H_metric_manager = MultiManager([H_siap_EKF_truth ], associator) # associator for generating SIAP metrics -H_metric_manager.add_data({'H_EKF_tracks':node_C2.tracks, - 'truths': truths, - 'H_detections': H_detections}, overwrite=False) +H_metric_manager.add_data({'H_EKF_tracks': fusion_node1B.tracks, + 'truths': truths, + 'H_detections': H_dets}, overwrite=False) H_metrics = H_metric_manager.generate_metrics() - # %% - H_siap_metrics = H_metrics['H_SIAP_EKF-truth'] H_siap_averages_EKF = {H_siap_metrics.get(metric) for metric in H_siap_metrics - if metric.startswith("SIAP") and not metric.endswith(" at times")} + if metric.startswith("SIAP") and not metric.endswith(" at times")} # %% # Compare Metrics -# ^^^^^^^^^^^^^^^ +# --------------- +# SIAP Metrics +# ^^^^^^^^^^^^ # # Below we plot a table of SIAP metrics for both architectures. This results in this comparison # table show that the results from the hierarchical architecture outperform the results from the @@ -435,9 +485,10 @@ def reduce_tracks(tracks): # Further below is plot of SIAP position accuracy over time for the duration of the simulation. # Smaller values represent higher accuracy + from stonesoup.metricgenerator.metrictables import SIAPDiffTableGenerator -SIAPDiffTableGenerator(NH_siap_averages_EKF, H_siap_averages_EKF).compute_metric() +SIAPDiffTableGenerator([NH_siap_averages_EKF, H_siap_averages_EKF]).compute_metric() # %% @@ -446,23 +497,38 @@ def reduce_tracks(tracks): combined_metrics = H_metrics | NH_metrics graph = MetricPlotter() graph.plot_metrics(combined_metrics, generator_names=['H_SIAP_EKF-truth', - 'NH_SIAP_EKF-truth'], + 'NH_SIAP_EKF-truth'], metric_names=['SIAP Position Accuracy at times'], color=['red', 'blue']) # %% -# Explanation of Results -# ---------------------- -# -# In the centralised architecture, measurements from the 'bad' sensor are passed to both the -# central fusion node (C), and the sensor fusion node (B). At node B, these measurements are -# fused with measurements from the 'good' sensor, and the output is sent to node C. -# -# At node C, the fused results from node B are once again fused with the measurements from node -# A, despite implicitly containing the information from sensor A already. Hence, we end up with a -# fused result that is biased towards the readings from sensor A. -# -# By altering the architecture through removing the edge from node A to node B, we are removing -# the incestual loop, and the resulting fusion at node C is just fusion of two disjoint sets of -# measurements. Although node C is still receiving the less accurate measurements, it is not -# biased towards the measurements from node A. +# PCRB Metric +# ^^^^^^^^^^^ + +from stonesoup.models.measurement.linear import LinearGaussian +from stonesoup.types.array import CovarianceMatrix, StateVectors +from stonesoup.metricgenerator.pcrbmetric import PCRBMetric + +pcrb = PCRBMetric(prior=prior, + transition_model=transition_model, + measurement_model=LinearGaussian(ndim_state=4, mapping=[0, 2], + noise_covar=CovarianceMatrix(np.diag([5., 5.]))), + sensor_locations=StateVectors([sensor1.position, sensor2.position]), + position_mapping=[0, 2], + velocity_mapping=[1, 3], + generator_name='PCRB Metrics' + ) + +# %% +manager = MultiManager([pcrb]) + +manager.add_data({'groundtruth_paths': truths}) + +metrics = manager.generate_metrics() + +# %% +pcrb_metrics = metrics['PCRB Metrics'] + +# %% +pcrb_metrics + diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 3759108fd..07995cb8f 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -2,6 +2,7 @@ from operator import attrgetter import pydot +from ordered_set import OrderedSet from ..base import Base, Property from .node import Node, SensorNode, RepeaterNode, FusionNode @@ -385,7 +386,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # Get rid of ground truths that have not yet happened # (ie GroundTruthState's with timestamp after self.current_time) - new_ground_truths = set() + new_ground_truths = OrderedSet() for ground_truth_path in ground_truths: # need an if len(states) == 0 continue condition here? new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) From 19f08fa4c7ac67f4e70a38c77a293fe47a81631b Mon Sep 17 00:00:00 2001 From: Pike Sam Date: Thu, 22 Aug 2024 11:04:43 +0100 Subject: [PATCH 124/170] Minor title change --- docs/tutorials/architecture/03_Avoiding_Data_Incest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 95b0375e3..e1ff3faf6 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -2,9 +2,9 @@ # coding: utf-8 """ -========================== -# 3 - Avoiding Data Incest -========================== +======================== +3 - Avoiding Data Incest +======================== """ import random From d3f9f9786976f6ee7593100afacb9c0c19cf2552 Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 27 Aug 2024 14:14:31 +0100 Subject: [PATCH 125/170] Remove commented cell from architecture tutorial 3 --- .../architecture/03_Avoiding_Data_Incest.py | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index e1ff3faf6..c73efa711 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -36,48 +36,7 @@ # We build two sensors to be assigned to the two sensor nodes # -from stonesoup.types.state import StateVector -from stonesoup.sensor.radar.radar import RadarRotatingBearingRange -from stonesoup.types.angle import Angle from stonesoup.models.clutter import ClutterModel - -# sf1 = 0.85 - -# sensor1 = RadarRotatingBearingRange( -# position_mapping=[0, 2], -# noise_covar=np.array([[sf1 * np.radians(0.5) ** 2, 0], -# [0, sf1 * 1 ** 2]]), -# ndim_state=4, -# position=np.array([[10], [20 - 40]]), -# rpm=60, -# fov_angle=np.radians(360), -# dwell_centre=StateVector([0.0]), -# max_range=np.inf, -# resolutions={'dwell_centre': Angle(np.radians(30))}, -# clutter_model=ClutterModel(clutter_rate=15, dist_params=((-100, 100), (-50, 60))) -# ) -# sensor1.timestamp = start_time - -# %% - -# sf2 = 0.85 -# sensor2 = RadarRotatingBearingRange( -# position_mapping=[0, 2], -# noise_covar=np.array([[sf2 * np.radians(0.5) ** 2, 0], -# [0, sf2 * 1 ** 2]]), -# ndim_state=4, -# position=np.array([[10], [3*20 - 40]]), -# rpm=60, -# fov_angle=np.radians(360), -# dwell_centre=StateVector([0.0]), -# max_range=np.inf, -# resolutions={'dwell_centre': Angle(np.radians(30))}, -# clutter_model=ClutterModel(clutter_rate=5, dist_params=((-100, 100), (-50, 60))) -# ) - -# sensor2.timestamp = start_time - -# %% from stonesoup.models.measurement.linear import LinearGaussian from stonesoup.types.state import CovarianceMatrix From 10d96e019ea4d6c24d97dc01340db9d8b255757a Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 27 Aug 2024 16:44:21 +0100 Subject: [PATCH 126/170] Update architecture branch to use new radar sensor resolution parameter. --- .../architecture/02_Information_and_Network_Architectures.py | 2 +- stonesoup/architecture/tests/conftest.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 9eac02ccf..22f93d85f 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -111,7 +111,7 @@ fov_angle=np.radians(360), dwell_centre=StateVector([0.0]), max_range=np.inf, - resolutions={'dwell_centre': Angle(np.radians(30))} + resolution=Angle(np.radians(30)) ) base_sensor.timestamp = start_time diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index c3b8f6549..db70186c6 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -160,7 +160,7 @@ def radar_sensors(times): fov_angle=np.radians(360), dwell_centre=StateVector([0.0]), max_range=np.inf, - resolutions={'dwell_centre': Angle(np.radians(30))} + resolution=Angle(np.radians(30)) ) sensor_set.add(sensor) for sensor in sensor_set: @@ -415,7 +415,7 @@ def generator_params(): fov_angle=np.radians(360), dwell_centre=StateVector([0.0]), max_range=np.inf, - resolutions={'dwell_centre': Angle(np.radians(30))} + resolution=Angle(np.radians(30)) ) predictor = KalmanPredictor(transition_model) From babf8d473e0d7348de79d9e0ada56274b50ad282 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman <95758965+orosoman-dstl@users.noreply.github.com> Date: Wed, 28 Aug 2024 15:51:19 +0100 Subject: [PATCH 127/170] Update stonesoup/architecture/__init__.py --- stonesoup/architecture/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 07995cb8f..02e8bf501 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -96,7 +96,6 @@ def shortest_path_dict(self): from node1 to node2 if key1=node1 and key2=node2. If no path exists from node1 to node2, a KeyError is raised. """ - # Initiate a new DiGraph as self.digraph isn't necessarily directed. g = nx.DiGraph() for edge in self.edges.edge_list: g.add_edge(edge[0], edge[1]) From 2d6dba4565b42558a718c2974e6b6a8e6e715d73 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman <95758965+orosoman-dstl@users.noreply.github.com> Date: Wed, 28 Aug 2024 15:51:54 +0100 Subject: [PATCH 128/170] Update stonesoup/architecture/__init__.py --- stonesoup/architecture/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 02e8bf501..20f4f955d 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -370,11 +370,9 @@ class InformationArchitecture(Architecture): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if isinstance(self, InformationArchitecture): - for node in self.all_nodes: - if isinstance(node, RepeaterNode): - raise TypeError("Information architecture should not contain any repeater " - "nodes") + if any([isinstance(node, RepeaterNode) for node in self.all_nodes]): + raise TypeError("Information architecture should not contain any repeater " + "nodes") for fusion_node in self.fusion_nodes: pass # fusion_node.tracker.set_time(self.current_time) From 2707f9cfaf0b7b4c0b3504b8f75520b29daf41a7 Mon Sep 17 00:00:00 2001 From: Oliver Rosoman <95758965+orosoman-dstl@users.noreply.github.com> Date: Wed, 28 Aug 2024 15:52:08 +0100 Subject: [PATCH 129/170] Update stonesoup/architecture/__init__.py --- stonesoup/architecture/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 20f4f955d..9ccc9db09 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -525,8 +525,6 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): else: edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) - # for node in self.processing_nodes: - # node.process() # This should happen when a new message is received for fuse_node in self.fusion_nodes: fuse_node.fuse() From 498b051c50f2909b322284b69d39cfebbea61e7e Mon Sep 17 00:00:00 2001 From: Oliver Rosoman <95758965+orosoman-dstl@users.noreply.github.com> Date: Wed, 28 Aug 2024 16:14:31 +0100 Subject: [PATCH 130/170] Update stonesoup/architecture/__init__.py --- stonesoup/architecture/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 9ccc9db09..5bbcb9937 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -96,7 +96,7 @@ def shortest_path_dict(self): from node1 to node2 if key1=node1 and key2=node2. If no path exists from node1 to node2, a KeyError is raised. """ - g = nx.DiGraph() + g = self.di_graph for edge in self.edges.edge_list: g.add_edge(edge[0], edge[1]) path = nx.all_pairs_shortest_path_length(g) From 6f837f6513dca681224c27a233e233631f0aa79c Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Wed, 28 Aug 2024 16:24:39 +0100 Subject: [PATCH 131/170] fix indent bug --- stonesoup/architecture/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 5bbcb9937..0cf948338 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -370,11 +370,11 @@ class InformationArchitecture(Architecture): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if any([isinstance(node, RepeaterNode) for node in self.all_nodes]): - raise TypeError("Information architecture should not contain any repeater " - "nodes") - for fusion_node in self.fusion_nodes: - pass # fusion_node.tracker.set_time(self.current_time) + if any([isinstance(node, RepeaterNode) for node in self.all_nodes]): + raise TypeError("Information architecture should not contain any repeater " + "nodes") + for fusion_node in self.fusion_nodes: + pass # fusion_node.tracker.set_time(self.current_time) def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: From 019979a1825658895b9f6d23609cd61a3152bbfd Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Wed, 28 Aug 2024 16:56:47 +0100 Subject: [PATCH 132/170] flake-8 fixes --- .../architecture/tests/test_architecture.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 0735dbfdb..767236a16 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -485,7 +485,7 @@ def test_information_arch_measure(edge_lists, ground_truths, times): all_detections = network.measure(ground_truths=ground_truths, current_time=start_time) # Check all_detections is a dictionary - assert type(all_detections) == dict + assert isinstance(all_detections, dict) # Check that number all_detections contains data for all sensor nodes assert all_detections.keys() == network.sensor_nodes @@ -494,10 +494,10 @@ def test_information_arch_measure(edge_lists, ground_truths, times): # of targets for sensornode in network.sensor_nodes: # Check that a detection is made for all 3 targets - assert(len(all_detections[sensornode])) == 3 - assert type(all_detections[sensornode]) == set + assert len(all_detections[sensornode]) == 3 + assert isinstance(all_detections[sensornode], set) for detection in all_detections[sensornode]: - assert type(detection) == TrueDetection + assert isinstance(detection, TrueDetection) for node in network.sensor_nodes: # Check that each sensor node has data held for the detection of all 3 targets @@ -511,13 +511,13 @@ def test_information_arch_measure_no_noise(edge_lists, ground_truths, times): all_detections = network.measure(ground_truths=ground_truths, current_time=start_time, noise=False) - assert type(all_detections) == dict + assert isinstance(all_detections, dict) assert all_detections.keys() == network.sensor_nodes for sensornode in network.sensor_nodes: - assert(len(all_detections[sensornode])) == 3 - assert type(all_detections[sensornode]) == set + assert len(all_detections[sensornode]) == 3 + assert isinstance(all_detections[sensornode], set) for detection in all_detections[sensornode]: - assert type(detection) == TrueDetection + assert isinstance(detection, TrueDetection) def test_information_arch_measure_no_detections(edge_lists, ground_truths, times): @@ -526,13 +526,13 @@ def test_information_arch_measure_no_detections(edge_lists, ground_truths, times network = InformationArchitecture(edges=edges, current_time=None) all_detections = network.measure(ground_truths=[], current_time=start_time) - assert type(all_detections) == dict + assert isinstance(all_detections, dict) assert all_detections.keys() == network.sensor_nodes # There should exist a key for each sensor node containing an empty list for sensornode in network.sensor_nodes: - assert(len(all_detections[sensornode])) == 0 - assert type(all_detections[sensornode]) == set + assert len(all_detections[sensornode]) == 0 + assert isinstance(all_detections[sensornode], set) def test_information_arch_measure_no_time(edge_lists, ground_truths): @@ -540,13 +540,13 @@ def test_information_arch_measure_no_time(edge_lists, ground_truths): network = InformationArchitecture(edges=edges) all_detections = network.measure(ground_truths=ground_truths) - assert type(all_detections) == dict + assert isinstance(all_detections, dict) assert all_detections.keys() == network.sensor_nodes for sensornode in network.sensor_nodes: - assert(len(all_detections[sensornode])) == 3 - assert type(all_detections[sensornode]) == set + assert len(all_detections[sensornode]) == 3 + assert isinstance(all_detections[sensornode], set) for detection in all_detections[sensornode]: - assert type(detection) == TrueDetection + assert isinstance(detection, TrueDetection) def test_fully_propagated(edge_lists, times, ground_truths): From e993ad986f9270f4b59f58a36705b02a3c226f58 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 30 Aug 2024 15:31:11 +0100 Subject: [PATCH 133/170] "Address review comments. Remove unnecessary code" --- ...2_Information_and_Network_Architectures.py | 8 +- stonesoup/architecture/__init__.py | 49 ++++++------ stonesoup/tracker/fusion.py | 74 ------------------- stonesoup/types/groundtruth.py | 7 -- stonesoup/types/hypothesis.py | 6 +- stonesoup/types/tests/test_groundtruth.py | 15 ---- 6 files changed, 29 insertions(+), 130 deletions(-) delete mode 100644 stonesoup/tracker/fusion.py diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 22f93d85f..fa85d3606 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -39,7 +39,7 @@ # %% # 1 - Ground Truth # ---------------- -# We start this tutorial by generating a set of :class:`~.GroundTruthPath`s as a basis for a +# We start this tutorial by generating a set of :class:`~.GroundTruthPath`\s as a basis for a # tracking simulation. @@ -160,9 +160,9 @@ # %% # 3 - Generate Identical Architectures # ------------------------------------ -# The NetworkArchitecture class has a property information_arch, which contains the -# information-architecture-representation of the underlying network architecture. This means -# that if we use the NetworkArchitectureGenerator class to generate a pair of identical network +# The :class:`~.NetworkArchitecture` class has a property information_arch, which contains the +# information architecture representation of the underlying network architecture. This means +# that if we use the :class:`~.NetworkArchitectureGenerator` class to generate a pair of identical network # architectures, we can extract the information architecture from one. # # This will provide us with two completely separate architecture classes: a network architecture, diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 0cf948338..c66c17962 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -1,21 +1,21 @@ from abc import abstractmethod +from datetime import datetime, timedelta from operator import attrgetter +from typing import List, Tuple, Collection, Set, Union, Dict +from ordered_set import OrderedSet +import graphviz +import numpy as np +import networkx as nx import pydot -from ordered_set import OrderedSet -from ..base import Base, Property -from .node import Node, SensorNode, RepeaterNode, FusionNode from .edge import Edges, DataPiece, Edge -from ..types.groundtruth import GroundTruthPath -from ..types.detection import TrueDetection, Clutter +from .node import Node, SensorNode, RepeaterNode, FusionNode from ._functions import _default_label_gen +from ..base import Base, Property +from ..types.detection import TrueDetection, Clutter +from ..types.groundtruth import GroundTruthPath -from typing import List, Tuple, Collection, Set, Union, Dict -import numpy as np -import networkx as nx -import graphviz -from datetime import datetime, timedelta class Architecture(Base): @@ -304,17 +304,18 @@ def is_hierarchical(self): """Returns `True` if the :class:`Architecture` is hierarchical, otherwise `False`. Uses the following logic: An architecture is hierarchical if and only if there exists only one node with 0 recipients and all other nodes have exactly 1 recipient.""" - if not len(self.top_level_nodes) == 1: + top_nodes = self.top_level_nodes + if len(self.top_level_nodes) != 1: return False for node in self.all_nodes: - if node not in self.top_level_nodes and len(self.recipients(node)) != 1: + if node not in top_nodes and len(self.recipients(node)) != 1: return False return True @property def is_centralised(self): """ - Returns 'True' if the :class:`Architecture` is hierarchical, otherwise 'False'. + Returns 'True' if the :class:`Architecture` is centralised, otherwise 'False'. Uses the following logic: An architecture is centralised if and only if there exists only one node with 0 recipients, and there exists a path to this node from every other node in the architecture. @@ -322,9 +323,8 @@ def is_centralised(self): top_nodes = self.top_level_nodes if len(top_nodes) != 1: return False - else: - top_node = top_nodes.pop() - for node in self.all_nodes - self.top_level_nodes: + top_node = top_nodes.pop() + for node in self.all_nodes - {top_node}: try: _ = self.shortest_path_dict[node][top_node] except KeyError: @@ -381,16 +381,14 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ all_detections = dict() - # Get rid of ground truths that have not yet happened - # (ie GroundTruthState's with timestamp after self.current_time) - new_ground_truths = OrderedSet() + # Filter out only the ground truths that have already happened at self.current_time + current_ground_truths = OrderedSet() for ground_truth_path in ground_truths: - # need an if len(states) == 0 continue condition here? - new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) + current_ground_truths.add(ground_truth_path[:self.current_time+1e-99]) for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() - for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): + for detection in sensor_node.sensor.measure(current_ground_truths, noise, **kwargs): all_detections[sensor_node].add(detection) for data in all_detections[sensor_node]: @@ -406,19 +404,16 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): for edge in self.edges.edges: if failed_edges and edge in failed_edges: edge._failed(self.current_time, time_increment) - continue # No data passed along these edges + continue # No data passed along these edges. # Initial update of message categories edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) - # fuse goes here? for data_piece, time_pertaining in edge.unsent_data: edge.send_message(data_piece, time_pertaining, data_piece.time_arrived) # Need to re-run update messages so that messages aren't left as 'pending' edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) - # for node in self.processing_nodes: - # node.process() # This should happen when a new message is received for fuse_node in self.fusion_nodes: fuse_node.fuse() @@ -478,7 +473,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd new_ground_truths = set() for ground_truth_path in ground_truths: # need an if len(states) == 0 continue condition here? - new_ground_truths.add(ground_truth_path.available_at_time(self.current_time)) + new_ground_truths.add(ground_truth_path[:self.current_time+1e-99]) for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() diff --git a/stonesoup/tracker/fusion.py b/stonesoup/tracker/fusion.py deleted file mode 100644 index b5b48b7e1..000000000 --- a/stonesoup/tracker/fusion.py +++ /dev/null @@ -1,74 +0,0 @@ -from abc import ABC - -from stonesoup.architecture.edge import FusionQueue -from .base import Tracker -from ..base import Property -from stonesoup.buffered_generator import BufferedGenerator -from stonesoup.reader.base import DetectionReader -from stonesoup.types.detection import Detection -from stonesoup.types.track import Track - - -class FusionTracker(Tracker, ABC): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._tracks = set() - self._current_time = None - - def set_time(self, time): - self._current_time = time - - -class DummyDetector(DetectionReader): - def __init__(self, *args, **kwargs): - super().__init__(self, *args, **kwargs) - self.current = kwargs['current'] - - @BufferedGenerator.generator_method - def detections_gen(self): - yield self.current - - -class SimpleFusionTracker(FusionTracker): # implement tracks method - """Presumes data from this node are detections, and from every other node are tracks - Acts as a wrapper around a base tracker. Track is fixed after the sliding window. - It exists within it, but the States may change. """ - base_tracker: Tracker = Property(doc="Tracker given to the fusion node") - sliding_window: int = Property(default=30, - doc="The number of time steps before the result is fixed") - queue: FusionQueue = Property(default=None, - doc="Queue which feeds in data") - track_fusion_tracker: Tracker = Property(doc="Tracker for fusing of multiple tracks together") - - @property - def tracks(self): - return self._tracks - - def __next__(self): - data_piece = self.queue.get() - if (self._current_time - data_piece.time_arrived).total_seconds() > self.sliding_window: - # data not in window - return - if data_piece.time_arrived > self._current_time: - raise ValueError("Not sure this should happen... check this") - - if isinstance(data_piece.data, Detection): - return next(self.base_tracker) # this won't work probably :( - # Need to feed in self.queue as base_tracker.detector_iter - # Also need to give the base tracker our tracks to treat as its own - elif isinstance(data_piece.data, Track): - pass - # Must take account if one track has been fused together already - - # like this? - # for tracks in [ctracks]: - # dummy_detector = DummyDetector(current=[time, tracks]) - # self.track_fusion_tracker.detector = - # Tracks2GaussianDetectionFeeder(dummy_detector) - # self.track_fusion_tracker.__iter__() - # _, tracks = next(self.track_fusion_tracker) - # self.track_fusion_tracker.update(tracks) - # - # return time, self.tracks - else: - raise TypeError(f"Data piece contained an incompatible type: {type(data_piece.data)}") diff --git a/stonesoup/types/groundtruth.py b/stonesoup/types/groundtruth.py index adc32b5d5..daa8fa2f8 100644 --- a/stonesoup/types/groundtruth.py +++ b/stonesoup/types/groundtruth.py @@ -42,13 +42,6 @@ def __init__(self, *args, **kwargs): if self.id is None: self.id = str(uuid.uuid4()) - def available_at_time(self, time: datetime): - new_path = [] - for state in self.states: - if state.timestamp <= time: - new_path.append(state) - return GroundTruthPath(new_path, self.id) - class CompositeGroundTruthState(CompositeState): """Composite ground truth state type. diff --git a/stonesoup/types/hypothesis.py b/stonesoup/types/hypothesis.py index 35fc5cff8..e3116122b 100644 --- a/stonesoup/types/hypothesis.py +++ b/stonesoup/types/hypothesis.py @@ -101,9 +101,9 @@ def weight(self): class SingleProbabilityHypothesis(ProbabilityHypothesis, SingleHypothesis): """Single Measurement Probability scored hypothesis subclass.""" - def __hash__(self): - return hash((self.probability, self.prediction, self.measurement, - self.measurement_prediction)) + # def __hash__(self): + # return hash((self.probability, self.prediction, self.measurement, + # self.measurement_prediction)) class JointHypothesis(Type, UserDict): diff --git a/stonesoup/types/tests/test_groundtruth.py b/stonesoup/types/tests/test_groundtruth.py index 512e01d61..3726fb13d 100644 --- a/stonesoup/types/tests/test_groundtruth.py +++ b/stonesoup/types/tests/test_groundtruth.py @@ -38,18 +38,3 @@ def test_composite_groundtruth(): sub_state3 = CategoricalGroundTruthState([0.6, 0.4], metadata={'shape': 'square'}) state = CompositeGroundTruthState(sub_states=[sub_state1, sub_state2, sub_state3]) assert state.metadata == {'colour': 'red', 'speed': 'fast', 'shape': 'square'} - - -def test_available_at_time(): - state_1 = GroundTruthState(np.array([[1]]), timestamp=datetime(2023, 3, 28, 16, 54, 1, 868643)) - state_2 = GroundTruthState(np.array([[1]]), timestamp=datetime(2023, 3, 28, 16, 54, 2, 868643)) - state_3 = GroundTruthState(np.array([[1]]), timestamp=datetime(2023, 3, 28, 16, 54, 3, 868643)) - path = GroundTruthPath([state_1, state_2, state_3]) - path_0 = path.available_at_time(datetime(2023, 3, 28, 16, 54, 0, 868643)) - assert len(path_0) == 0 - path_2 = path.available_at_time(datetime(2023, 3, 28, 16, 54, 2, 868643)) - assert len(path_2) == 2 - path_2_1 = path.available_at_time(datetime(2023, 3, 28, 16, 54, 2, 999643)) - assert len(path_2_1) == 2 - path_3 = path.available_at_time(datetime(2023, 3, 28, 16, 54, 20, 868643)) - assert len(path_3) == 3 From b3ba8cfe9fb99e2c28bd8dd4b46e155e47448692 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 30 Aug 2024 15:38:49 +0100 Subject: [PATCH 134/170] remove tests for fusion tracker --- stonesoup/tracker/tests/test_fusion.py | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 stonesoup/tracker/tests/test_fusion.py diff --git a/stonesoup/tracker/tests/test_fusion.py b/stonesoup/tracker/tests/test_fusion.py deleted file mode 100644 index 826bea16f..000000000 --- a/stonesoup/tracker/tests/test_fusion.py +++ /dev/null @@ -1,9 +0,0 @@ -from ..fusion import SimpleFusionTracker -from ..simple import MultiTargetTracker - - -def test_fusion_tracker(initiator, deleter, detector, data_associator, updater): - base_tracker = MultiTargetTracker( - initiator, deleter, detector, data_associator, updater) - _ = SimpleFusionTracker(base_tracker, 30) - assert True From 1fae468176814701913a48ec24879b8049fb2f79 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 30 Aug 2024 16:19:50 +0100 Subject: [PATCH 135/170] Use timedelta to add to a datetime --- stonesoup/architecture/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index c66c17962..3a4724a7e 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -384,7 +384,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # Filter out only the ground truths that have already happened at self.current_time current_ground_truths = OrderedSet() for ground_truth_path in ground_truths: - current_ground_truths.add(ground_truth_path[:self.current_time+1e-99]) + current_ground_truths.add(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)]) for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() @@ -473,7 +473,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd new_ground_truths = set() for ground_truth_path in ground_truths: # need an if len(states) == 0 continue condition here? - new_ground_truths.add(ground_truth_path[:self.current_time+1e-99]) + new_ground_truths.add(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)]) for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() From df8ba83a9a1791d6318b6d0222304272bb0e20f8 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 30 Aug 2024 16:38:42 +0100 Subject: [PATCH 136/170] Slicing GroundTruthPath converts back to StateMutableSequence - correct this --- stonesoup/architecture/__init__.py | 4 ++-- stonesoup/types/hypothesis.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 3a4724a7e..132d976c6 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -384,7 +384,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # Filter out only the ground truths that have already happened at self.current_time current_ground_truths = OrderedSet() for ground_truth_path in ground_truths: - current_ground_truths.add(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)]) + current_ground_truths.add(GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)])) for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() @@ -473,7 +473,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd new_ground_truths = set() for ground_truth_path in ground_truths: # need an if len(states) == 0 continue condition here? - new_ground_truths.add(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)]) + new_ground_truths.add(GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)])) for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() diff --git a/stonesoup/types/hypothesis.py b/stonesoup/types/hypothesis.py index e3116122b..35fc5cff8 100644 --- a/stonesoup/types/hypothesis.py +++ b/stonesoup/types/hypothesis.py @@ -101,9 +101,9 @@ def weight(self): class SingleProbabilityHypothesis(ProbabilityHypothesis, SingleHypothesis): """Single Measurement Probability scored hypothesis subclass.""" - # def __hash__(self): - # return hash((self.probability, self.prediction, self.measurement, - # self.measurement_prediction)) + def __hash__(self): + return hash((self.probability, self.prediction, self.measurement, + self.measurement_prediction)) class JointHypothesis(Type, UserDict): From 2a95fd02fe28e95e504b670b2f556c57f09681ae Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 30 Aug 2024 19:30:13 +0100 Subject: [PATCH 137/170] cleanup and mopve towards tests passing --- stonesoup/architecture/__init__.py | 17 ++++++++++------- .../architecture/tests/test_architecture.py | 7 +++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 132d976c6..032f0e838 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -384,7 +384,9 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # Filter out only the ground truths that have already happened at self.current_time current_ground_truths = OrderedSet() for ground_truth_path in ground_truths: - current_ground_truths.add(GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)])) + available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)]) + if len(available_gtp) > 0: + current_ground_truths.add(available_gtp) for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() @@ -468,16 +470,17 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd """ Similar to the method for :class:`~.SensorSuite`. Updates each node. """ all_detections = dict() - # Get rid of ground truths that have not yet happened - # (ie GroundTruthState's with timestamp after self.current_time) - new_ground_truths = set() + # Filter out only the ground truths that have already happened at self.current_time + current_ground_truths = set() for ground_truth_path in ground_truths: - # need an if len(states) == 0 continue condition here? - new_ground_truths.add(GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)])) + available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)]) + if len(available_gtp) > 0: + current_ground_truths.add(available_gtp) + for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() - for detection in sensor_node.sensor.measure(new_ground_truths, noise, **kwargs): + for detection in sensor_node.sensor.measure(current_ground_truths, noise, **kwargs): all_detections[sensor_node].add(detection) for data in all_detections[sensor_node]: diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 767236a16..3b1b8291f 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -728,14 +728,13 @@ def test_net_arch_fully_propagated(edge_lists, times, ground_truths, radar_nodes assert len(node.data_held['created'][key]) == 3 # Put some data in a Node's 'messages_to_pass_on' - edge = edges.get((radar_nodes['a'], radar_nodes['c'])) - node = radar_nodes['a'] + edge = edges.get((node_A, repeaternode1))[0] message = Message(edge, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, DataPiece(node, node, 'test_data', datetime.datetime(2016, 1, 2, 3, 4, 5))) - node.messages_to_pass_on.append(message) + edge.sender.messages_to_pass_on.append(message) # Network should not be fully propagated - assert network_arch.fully_propagated is False + assert not network_arch.fully_propagated network_arch.propagate(time_increment=1) From 71667708e34994ee0ababbbc75257bc82af5b718 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 30 Aug 2024 19:39:38 +0100 Subject: [PATCH 138/170] minor changes --- stonesoup/architecture/edge.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index b53aa23ea..a939d436a 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -11,6 +11,7 @@ from ..types.detection import Detection from ..types.hypothesis import Hypothesis from ._functions import _dict_set +from .node import SensorFusionNode if TYPE_CHECKING: from .node import Node @@ -203,15 +204,11 @@ def unpassed_data(self): @property def unsent_data(self): - from stonesoup.architecture.node import SensorFusionNode """Data held by the sender that has not been sent to the recipient.""" unsent = [] if isinstance(type(self.sender.data_held), type(None)) or self.sender.data_held is None: return unsent else: - # for status in ["fused"] if \ - # str(type(self.nodes[0])).split('.')[-1].split("'")[0] == 'SensorFusionNode' \ - # else ["fused", "created"]: for status in ["fused"] if isinstance(self.nodes[0], SensorFusionNode) else \ ["fused", "created"]: for time_pertaining in self.sender.data_held[status]: From 95319c01d133a7ac9e04ced87e7ebf11ea74ae94 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 2 Sep 2024 13:27:50 +0100 Subject: [PATCH 139/170] Fix bug caused by timedelta rounding to zero, Update fully propagated tests. --- stonesoup/architecture/__init__.py | 10 +- stonesoup/architecture/edge.py | 14 +- .../architecture/tests/test_architecture.py | 155 ++++++++++++------ 3 files changed, 116 insertions(+), 63 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 032f0e838..a7f6bb069 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -17,7 +17,6 @@ from ..types.groundtruth import GroundTruthPath - class Architecture(Base): """Abstract Architecture Base class. Subclasses must implement the :meth:`~Architecture.propogate` method. @@ -96,7 +95,8 @@ def shortest_path_dict(self): from node1 to node2 if key1=node1 and key2=node2. If no path exists from node1 to node2, a KeyError is raised. """ - g = self.di_graph + # Cannot use self.di_graph as it is not adjusted when edges are removed after instantiation of architecture. + g = nx.DiGraph() for edge in self.edges.edge_list: g.add_edge(edge[0], edge[1]) path = nx.all_pairs_shortest_path_length(g) @@ -373,8 +373,6 @@ def __init__(self, *args, **kwargs): if any([isinstance(node, RepeaterNode) for node in self.all_nodes]): raise TypeError("Information architecture should not contain any repeater " "nodes") - for fusion_node in self.fusion_nodes: - pass # fusion_node.tracker.set_time(self.current_time) def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, **kwargs) -> Dict[SensorNode, Set[Union[TrueDetection, Clutter]]]: @@ -384,7 +382,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # Filter out only the ground truths that have already happened at self.current_time current_ground_truths = OrderedSet() for ground_truth_path in ground_truths: - available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)]) + available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + timedelta(microseconds=1)]) if len(available_gtp) > 0: current_ground_truths.add(available_gtp) @@ -473,7 +471,7 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # Filter out only the ground truths that have already happened at self.current_time current_ground_truths = set() for ground_truth_path in ground_truths: - available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-99)]) + available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-6)]) if len(available_gtp) > 0: current_ground_truths.add(available_gtp) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index a939d436a..ffcb5e9cc 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -11,7 +11,6 @@ from ..types.detection import Detection from ..types.hypothesis import Hypothesis from ._functions import _dict_set -from .node import SensorFusionNode if TYPE_CHECKING: from .node import Node @@ -204,13 +203,22 @@ def unpassed_data(self): @property def unsent_data(self): + from .node import SensorFusionNode, SensorNode, FusionNode """Data held by the sender that has not been sent to the recipient.""" unsent = [] if isinstance(type(self.sender.data_held), type(None)) or self.sender.data_held is None: return unsent else: - for status in ["fused"] if isinstance(self.nodes[0], SensorFusionNode) else \ - ["fused", "created"]: + if isinstance(self.nodes[0], SensorFusionNode): + statuses = ["fused", "created"] + elif isinstance(self.nodes[0], FusionNode): + statuses = ["fused"] + elif isinstance(self.nodes[0], SensorNode): + statuses = ["created"] + else: + raise NotImplementedError("Node should be a Sensor, Fusion or SensorFusion node.") + + for status in statuses: for time_pertaining in self.sender.data_held[status]: for data_piece in self.sender.data_held[status][time_pertaining]: # Data will be sent to any nodes it hasn't been sent to before diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 3b1b8291f..2b95bcc0d 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -6,8 +6,10 @@ from stonesoup.architecture import InformationArchitecture, NetworkArchitecture, \ NonPropagatingArchitecture from ..edge import Edge, Edges, FusionQueue, Message, DataPiece +from ..generator import NetworkArchitectureGenerator from ..node import RepeaterNode, SensorNode, FusionNode from stonesoup.types.detection import TrueDetection +from ...types.track import Track def test_hierarchical_plot(nodes, edge_lists): @@ -675,71 +677,116 @@ def test_network_arch(radar_sensors, ground_truths, tracker, track_tracker, time assert network_arch.information_arch.fusion_nodes == {node_C, node_F, node_G} -def test_net_arch_fully_propagated(edge_lists, times, ground_truths, radar_nodes, radar_sensors, - tracker, track_tracker): - start_time = times['start'] - sensor_set = radar_sensors - fq = FusionQueue() - - node_A = SensorNode(sensor=sensor_set[0], label='SensorNode A') - node_B = SensorNode(sensor=sensor_set[2], label='SensorNode B') - - node_C_tracker = copy.deepcopy(tracker) - node_C_tracker.detector = FusionQueue() - node_C = FusionNode(tracker=node_C_tracker, fusion_queue=node_C_tracker.detector, latency=0, - label='FusionNode C') - - ## - node_D = SensorNode(sensor=sensor_set[1], label='SensorNode D') - node_E = SensorNode(sensor=sensor_set[3], label='SensorNode E') - - node_F_tracker = copy.deepcopy(tracker) - node_F_tracker.detector = FusionQueue() - node_F = FusionNode(tracker=node_F_tracker, fusion_queue=node_F_tracker.detector, latency=0) - - node_H = SensorNode(sensor=sensor_set[4]) - - node_G = FusionNode(tracker=track_tracker, fusion_queue=fq, latency=0) - - repeaternode1 = RepeaterNode(label='RepeaterNode 1') - repeaternode2 = RepeaterNode(label='RepeaterNode 2') - - edges = Edges([Edge((node_A, repeaternode1), edge_latency=0.5), - Edge((repeaternode1, node_C), edge_latency=0.5), - Edge((node_B, node_C)), - Edge((node_A, repeaternode2), edge_latency=0.5), - Edge((repeaternode2, node_C)), - Edge((repeaternode1, repeaternode2)), - Edge((node_D, node_F)), Edge((node_E, node_F)), - Edge((node_C, node_G), edge_latency=0), - Edge((node_F, node_G), edge_latency=0), - Edge((node_H, node_G)) - ]) - - network_arch = NetworkArchitecture( - edges=edges, - current_time=start_time) - - network_arch.measure(ground_truths=ground_truths, noise=True) - - for node in network_arch.sensor_nodes: +# def test_net_arch_fully_propagated(edge_lists, times, ground_truths, radar_nodes, radar_sensors, +# tracker, track_tracker): +# start_time = times['start'] +# sensor_set = radar_sensors +# fq = FusionQueue() +# +# node_A = SensorNode(sensor=sensor_set[0], label='SensorNode A') +# node_B = SensorNode(sensor=sensor_set[2], label='SensorNode B') +# +# node_C_tracker = copy.deepcopy(tracker) +# node_C_tracker.detector = FusionQueue() +# node_C = FusionNode(tracker=node_C_tracker, fusion_queue=node_C_tracker.detector, latency=0, +# label='FusionNode C') +# +# ## +# node_D = SensorNode(sensor=sensor_set[1], label='SensorNode D') +# node_E = SensorNode(sensor=sensor_set[3], label='SensorNode E') +# +# node_F_tracker = copy.deepcopy(tracker) +# node_F_tracker.detector = FusionQueue() +# node_F = FusionNode(tracker=node_F_tracker, fusion_queue=node_F_tracker.detector, latency=0) +# +# node_H = SensorNode(sensor=sensor_set[4]) +# +# node_G = FusionNode(tracker=track_tracker, fusion_queue=fq, latency=0) +# +# repeaternode1 = RepeaterNode(label='RepeaterNode 1') +# repeaternode2 = RepeaterNode(label='RepeaterNode 2') +# +# edges = Edges([Edge((node_A, repeaternode1), edge_latency=0.5), +# Edge((repeaternode1, node_C), edge_latency=0.5), +# Edge((node_B, node_C)), +# Edge((node_A, repeaternode2), edge_latency=0.5), +# Edge((repeaternode2, node_C)), +# Edge((repeaternode1, repeaternode2)), +# Edge((node_D, node_F)), Edge((node_E, node_F)), +# Edge((node_C, node_G), edge_latency=0), +# Edge((node_F, node_G), edge_latency=0), +# Edge((node_H, node_G)) +# ]) +# +# network_arch = NetworkArchitecture( +# edges=edges, +# current_time=start_time) +# +# network_arch.measure(ground_truths=ground_truths, noise=True) +# +# for node in network_arch.sensor_nodes: +# # Check that each sensor node has data held for the detection of all 3 targets +# for key in node.data_held['created'].keys(): +# assert len(node.data_held['created'][key]) == 3 +# +# # Put some data in a Node's 'messages_to_pass_on' +# edge = edges.get((node_A, repeaternode1))[0] +# message = Message(edge, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, +# DataPiece(node_A, node_A, 'test_data', datetime.datetime(2016, 1, 2, 3, 4, 5))) +# node_A.messages_to_pass_on.append(message) +# +# # Network should not be fully propagated +# assert not network_arch.fully_propagated +# +# network_arch.propagate(time_increment=1) +# +# # Network should now be fully propagated +# assert network_arch.fully_propagated + +def test_net_arch_fully_propagated(generator_params, ground_truths): + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + + gen = NetworkArchitectureGenerator(arch_type='hierarchical', + start_time=start_time, + mean_degree=2, + node_ratio=[3, 2, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=1) + + arch = gen.generate()[0] + + # Pre-test checks on generated architecture + assert isinstance(arch, NetworkArchitecture) + assert len(arch.sensor_nodes) == sum(gen.node_ratio[:2]) + assert len(arch.fusion_nodes) == sum(gen.node_ratio[1:]) + + arch.measure(ground_truths=ground_truths, noise=True) + + for node in arch.sensor_nodes: # Check that each sensor node has data held for the detection of all 3 targets for key in node.data_held['created'].keys(): + print(key) assert len(node.data_held['created'][key]) == 3 - # Put some data in a Node's 'messages_to_pass_on' - edge = edges.get((node_A, repeaternode1))[0] + edge = {edge for edge in arch.edges if + isinstance(edge.nodes[0], RepeaterNode) and + isinstance(edge.nodes[1], FusionNode)}.pop() + message = Message(edge, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, - DataPiece(node, node, 'test_data', datetime.datetime(2016, 1, 2, 3, 4, 5))) + DataPiece(edge.sender, edge.sender, Track([]), datetime.datetime(2016, 1, 2, 3, 4, 5))) + edge.sender.messages_to_pass_on.append(message) # Network should not be fully propagated - assert not network_arch.fully_propagated + assert not arch.fully_propagated - network_arch.propagate(time_increment=1) + arch.propagate(time_increment=1) # Network should now be fully propagated - assert network_arch.fully_propagated + assert arch.fully_propagated def test_non_propagating_arch(edge_lists, times): From b26a9939ecfc04a8b0674f2c6f3a92d8e740b619 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 2 Sep 2024 13:53:07 +0100 Subject: [PATCH 140/170] Fix bug with Edge.unsent_data not accepting RepeaterNodes --- stonesoup/architecture/edge.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index ffcb5e9cc..d30cfba28 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -203,18 +203,20 @@ def unpassed_data(self): @property def unsent_data(self): - from .node import SensorFusionNode, SensorNode, FusionNode + from .node import SensorFusionNode, SensorNode, FusionNode, RepeaterNode, Node """Data held by the sender that has not been sent to the recipient.""" unsent = [] if isinstance(type(self.sender.data_held), type(None)) or self.sender.data_held is None: return unsent else: - if isinstance(self.nodes[0], SensorFusionNode): + if isinstance(self.nodes[0], (SensorFusionNode, Node)): statuses = ["fused", "created"] elif isinstance(self.nodes[0], FusionNode): statuses = ["fused"] elif isinstance(self.nodes[0], SensorNode): statuses = ["created"] + elif isinstance(self.nodes[0], (RepeaterNode, Node)): + statuses = [] else: raise NotImplementedError("Node should be a Sensor, Fusion or SensorFusion node.") From f64d0eabb45a6db3d1e56d31f9fc27ba3fefbb3d Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 2 Sep 2024 13:59:05 +0100 Subject: [PATCH 141/170] Fix flake-8 errors on Architecture module after bug fixes --- stonesoup/architecture/__init__.py | 10 +-- .../architecture/tests/test_architecture.py | 69 +------------------ stonesoup/types/groundtruth.py | 2 - stonesoup/types/tests/test_groundtruth.py | 2 - 4 files changed, 8 insertions(+), 75 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index a7f6bb069..57e95af6b 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -95,7 +95,8 @@ def shortest_path_dict(self): from node1 to node2 if key1=node1 and key2=node2. If no path exists from node1 to node2, a KeyError is raised. """ - # Cannot use self.di_graph as it is not adjusted when edges are removed after instantiation of architecture. + # Cannot use self.di_graph as it is not adjusted when edges are removed after + # instantiation of architecture. g = nx.DiGraph() for edge in self.edges.edge_list: g.add_edge(edge[0], edge[1]) @@ -382,7 +383,8 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # Filter out only the ground truths that have already happened at self.current_time current_ground_truths = OrderedSet() for ground_truth_path in ground_truths: - available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + timedelta(microseconds=1)]) + available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + + timedelta(microseconds=1)]) if len(available_gtp) > 0: current_ground_truths.add(available_gtp) @@ -471,11 +473,11 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd # Filter out only the ground truths that have already happened at self.current_time current_ground_truths = set() for ground_truth_path in ground_truths: - available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + timedelta(seconds=1e-6)]) + available_gtp = GroundTruthPath(ground_truth_path[:self.current_time + + timedelta(seconds=1e-6)]) if len(available_gtp) > 0: current_ground_truths.add(available_gtp) - for sensor_node in self.sensor_nodes: all_detections[sensor_node] = set() for detection in sensor_node.sensor.measure(current_ground_truths, noise, **kwargs): diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 2b95bcc0d..5a7261d0a 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -677,72 +677,6 @@ def test_network_arch(radar_sensors, ground_truths, tracker, track_tracker, time assert network_arch.information_arch.fusion_nodes == {node_C, node_F, node_G} -# def test_net_arch_fully_propagated(edge_lists, times, ground_truths, radar_nodes, radar_sensors, -# tracker, track_tracker): -# start_time = times['start'] -# sensor_set = radar_sensors -# fq = FusionQueue() -# -# node_A = SensorNode(sensor=sensor_set[0], label='SensorNode A') -# node_B = SensorNode(sensor=sensor_set[2], label='SensorNode B') -# -# node_C_tracker = copy.deepcopy(tracker) -# node_C_tracker.detector = FusionQueue() -# node_C = FusionNode(tracker=node_C_tracker, fusion_queue=node_C_tracker.detector, latency=0, -# label='FusionNode C') -# -# ## -# node_D = SensorNode(sensor=sensor_set[1], label='SensorNode D') -# node_E = SensorNode(sensor=sensor_set[3], label='SensorNode E') -# -# node_F_tracker = copy.deepcopy(tracker) -# node_F_tracker.detector = FusionQueue() -# node_F = FusionNode(tracker=node_F_tracker, fusion_queue=node_F_tracker.detector, latency=0) -# -# node_H = SensorNode(sensor=sensor_set[4]) -# -# node_G = FusionNode(tracker=track_tracker, fusion_queue=fq, latency=0) -# -# repeaternode1 = RepeaterNode(label='RepeaterNode 1') -# repeaternode2 = RepeaterNode(label='RepeaterNode 2') -# -# edges = Edges([Edge((node_A, repeaternode1), edge_latency=0.5), -# Edge((repeaternode1, node_C), edge_latency=0.5), -# Edge((node_B, node_C)), -# Edge((node_A, repeaternode2), edge_latency=0.5), -# Edge((repeaternode2, node_C)), -# Edge((repeaternode1, repeaternode2)), -# Edge((node_D, node_F)), Edge((node_E, node_F)), -# Edge((node_C, node_G), edge_latency=0), -# Edge((node_F, node_G), edge_latency=0), -# Edge((node_H, node_G)) -# ]) -# -# network_arch = NetworkArchitecture( -# edges=edges, -# current_time=start_time) -# -# network_arch.measure(ground_truths=ground_truths, noise=True) -# -# for node in network_arch.sensor_nodes: -# # Check that each sensor node has data held for the detection of all 3 targets -# for key in node.data_held['created'].keys(): -# assert len(node.data_held['created'][key]) == 3 -# -# # Put some data in a Node's 'messages_to_pass_on' -# edge = edges.get((node_A, repeaternode1))[0] -# message = Message(edge, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, -# DataPiece(node_A, node_A, 'test_data', datetime.datetime(2016, 1, 2, 3, 4, 5))) -# node_A.messages_to_pass_on.append(message) -# -# # Network should not be fully propagated -# assert not network_arch.fully_propagated -# -# network_arch.propagate(time_increment=1) -# -# # Network should now be fully propagated -# assert network_arch.fully_propagated - def test_net_arch_fully_propagated(generator_params, ground_truths): start_time = generator_params['start_time'] base_sensor = generator_params['base_sensor'] @@ -776,7 +710,8 @@ def test_net_arch_fully_propagated(generator_params, ground_truths): isinstance(edge.nodes[1], FusionNode)}.pop() message = Message(edge, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, - DataPiece(edge.sender, edge.sender, Track([]), datetime.datetime(2016, 1, 2, 3, 4, 5))) + DataPiece(edge.sender, edge.sender, Track([]), + datetime.datetime(2016, 1, 2, 3, 4, 5))) edge.sender.messages_to_pass_on.append(message) diff --git a/stonesoup/types/groundtruth.py b/stonesoup/types/groundtruth.py index daa8fa2f8..0639941f8 100644 --- a/stonesoup/types/groundtruth.py +++ b/stonesoup/types/groundtruth.py @@ -4,8 +4,6 @@ from .state import State, StateMutableSequence, CategoricalState, CompositeState from ..base import Property -from datetime import datetime - class GroundTruthState(State): """Ground Truth State type""" diff --git a/stonesoup/types/tests/test_groundtruth.py b/stonesoup/types/tests/test_groundtruth.py index 3726fb13d..1d21b24b9 100644 --- a/stonesoup/types/tests/test_groundtruth.py +++ b/stonesoup/types/tests/test_groundtruth.py @@ -3,8 +3,6 @@ from ..groundtruth import GroundTruthState, GroundTruthPath, CategoricalGroundTruthState, \ CompositeGroundTruthState -from datetime import datetime - def test_groundtruthpath(): empty_path = GroundTruthPath() From a03822d7e98c7b6e8adfa1adacb9c2d794fceb53 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 5 Sep 2024 09:45:13 +0100 Subject: [PATCH 142/170] Add docstrings to architecture.__init__.py --- stonesoup/architecture/__init__.py | 98 +++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 21 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 57e95af6b..b60f6b93a 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -54,6 +54,7 @@ def __init__(self, *args, **kwargs): self.current_time = datetime.now() self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) + self._viz_graph = None if self.force_connected and not self.is_connected and len(self) > 0: raise ValueError("The graph is not connected. Use force_connected=False, " @@ -69,7 +70,17 @@ def __init__(self, *args, **kwargs): self.di_graph.nodes[node].update(self._node_kwargs(node)) def recipients(self, node: Node): - """Returns a set of all nodes to which the input node has a direct edge to""" + """Returns a set of all nodes to which the input node has a direct edge to + + Args: + node (Node): Node of which to return the recipients of. + + Raises: + ValueError: Errors if given node is not in the Architecture. + + Returns: + set: Set of nodes that are recipients of the given node. + """ if node not in self.all_nodes: raise ValueError("Node not in this architecture") recipients = set() @@ -79,7 +90,17 @@ def recipients(self, node: Node): return recipients def senders(self, node: Node): - """Returns a set of all nodes to which the input node has a direct edge from""" + """Returns a set of all nodes to which the input node has a direct edge from + + Args: + node (Node): Node of which to return the senders of. + + Raises: + ValueError: Errors if given node is not in the Architecture. + + Returns: + set: Set of nodes that are senders to the given node. + """ if node not in self.all_nodes: raise ValueError("Node not in this architecture") senders = set() @@ -90,10 +111,13 @@ def senders(self, node: Node): @property def shortest_path_dict(self): - """ - Returns a dictionary where dict[key1][key2] gives the distance of the shortest path + """Returns a dictionary where dict[key1][key2] gives the distance of the shortest path from node1 to node2 if key1=node1 and key2=node2. If no path exists from node1 to node2, a KeyError is raised. + + Returns: + dict: Nested dictionary where dict[node1][node2] gives the distance of the shortest + path from node1 to node2. """ # Cannot use self.di_graph as it is not adjusted when edges are removed after # instantiation of architecture. @@ -106,7 +130,12 @@ def shortest_path_dict(self): @property def top_level_nodes(self): - """Returns a list of nodes with no recipients""" + """Returns a list of 'top level nodes' - These are nodes with no recipients. E.g. the + single node at the top of a hierarchical architecture. + + Returns: + set: Set of nodes that have no recipients. + """ top_nodes = set() for node in self.all_nodes: if len(self.recipients(node)) == 0: @@ -115,9 +144,14 @@ def top_level_nodes(self): return top_nodes def number_of_leaves(self, node: Node): - """ - Returns the number of leaf nodes which are connected to the node given as a parameter by a - path from the leaf node to the parameter node. + """Returns the number of leaf nodes which are connected to the node given as a parameter + by apath from the leaf node to the parameter node. + + Args: + node (Node): Node of which to calculate number of leaf nodes. + + Returns: + int: Number of leaf nodes that are connected to a given node. """ node_leaves = set() non_leaves = 0 @@ -132,14 +166,16 @@ def number_of_leaves(self, node: Node): node_leaves.add(leaf_node) except KeyError: non_leaves += 1 - else: - return len(node_leaves) + + return len(node_leaves) @property def leaf_nodes(self): - """ - Returns all the nodes in the :class:`Architecture` which have no sender nodes. i.e. all - nodes that do not receive any data from other nodes. + """Returns all the nodes in the :class:`Architecture` which have no sender nodes. i.e. + all nodes that do not receive any data from other nodes. + + Returns: + set: Set of all leaf nodes that exist in the Architecture """ leaf_nodes = set() for node in self.all_nodes: @@ -154,15 +190,19 @@ def propagate(self, time_increment: float): @property def all_nodes(self): - """ - Returns a set of all Nodes in the :class:`Architecture`. + """Returns a set of all Nodes in the :class:`Architecture`. + + Returns: + set: Set of all nodes in the Architecture """ return set(self.di_graph.nodes) @property def sensor_nodes(self): - """ - Returns a set of all SensorNodes in the :class:`Architecture`. + """Returns a set of all SensorNodes in the :class:`Architecture`. + + Returns: + set: Set of nodes in the Architecture that have a Sensor. """ sensor_nodes = set() for node in self.all_nodes: @@ -172,8 +212,10 @@ def sensor_nodes(self): @property def fusion_nodes(self): - """ - Returns a set of all FusionNodes in the :class:`Architecture`. + """Returns a set of all FusionNodes in the :class:`Architecture`. + + Returns: + set: Set of nodes in the Architecture that perform data fusion. """ fusion = set() for node in self.all_nodes: @@ -183,9 +225,13 @@ def fusion_nodes(self): @property def repeater_nodes(self): + """Returns a set of all RepeaterNodes in the :class:`Architecture`. + + Returns: + set: Set of nodes in the Architecture whose only role is to link two other nodes + together. """ - Returns a set of all RepeaterNodes in the :class:`Architecture`. - """ + repeater_nodes = set() for node in self.all_nodes: if isinstance(node, RepeaterNode): @@ -334,10 +380,20 @@ def is_centralised(self): @property def is_connected(self): + """Property of Architecture class stating whether the graph is connected or not. + + Returns: + bool: Returns True if graph is connected, otherwise False. + """ return nx.is_connected(self.to_undirected) @property def to_undirected(self): + """Returns an undirected version of self.digraph + + Returns: + _type_: _description_ + """ return self.di_graph.to_undirected() def __len__(self): From 0da6703d87893a7375d156950c0b539926433c44 Mon Sep 17 00:00:00 2001 From: spike Date: Thu, 5 Sep 2024 15:55:48 +0100 Subject: [PATCH 143/170] Improved coverage on arhitecture tests --- stonesoup/architecture/__init__.py | 42 +++++++++--------- stonesoup/architecture/edge.py | 18 ++------ stonesoup/architecture/generator.py | 8 ++-- stonesoup/architecture/tests/conftest.py | 2 +- .../architecture/tests/test_architecture.py | 43 ++++++++++++++++++- stonesoup/architecture/tests/test_edge.py | 3 ++ .../architecture/tests/test_generator.py | 19 ++++++++ 7 files changed, 94 insertions(+), 41 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index b60f6b93a..2e4a55691 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -156,16 +156,15 @@ def number_of_leaves(self, node: Node): node_leaves = set() non_leaves = 0 - if node in self.leaf_nodes: - return 1 - else: - for leaf_node in self.leaf_nodes: - try: - shortest_path = self.shortest_path_dict[leaf_node][node] - if shortest_path != 0: - node_leaves.add(leaf_node) - except KeyError: - non_leaves += 1 + for leaf_node in self.leaf_nodes: + try: + shortest_path = self.shortest_path_dict[leaf_node][node] + if node != leaf_node or shortest_path != 0: + node_leaves.add(leaf_node) + else: + return 1 + except KeyError: + non_leaves += 1 return len(node_leaves) @@ -459,10 +458,12 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd def propagate(self, time_increment: float, failed_edges: Collection = None): """Performs the propagation of the measurements through the network""" + # Update each edge with messages received/sent for edge in self.edges.edges: - if failed_edges and edge in failed_edges: - edge._failed(self.current_time, time_increment) - continue # No data passed along these edges. + # TODO: Future work - Introduce failed edges functionality + # if failed_edges and edge in failed_edges: + # edge._failed(self.current_time, time_increment) + # continue # No data passed along these edges. # Initial update of message categories edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) @@ -549,12 +550,12 @@ def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.nd def propagate(self, time_increment: float, failed_edges: Collection = None): """Performs the propagation of the measurements through the network""" - # Update each edge with messages received/sent for edge in self.edges.edges: - if failed_edges and edge in failed_edges: - edge._failed(self.current_time, time_increment) - continue # No data passed along these edges + # TODO: Future work - Introduce failed edges functionality + # if failed_edges and edge in failed_edges: + # edge._failed(self.current_time, time_increment) + # continue # No data passed along these edges # Initial update of message categories if edge.recipient not in self.information_arch.all_nodes: @@ -596,11 +597,10 @@ def fully_propagated(self): if edge.sender in self.information_arch.all_nodes: if len(edge.unsent_data) != 0: return False - if len(edge.unpassed_data) != 0: - return False - else: - if len(edge.unpassed_data) != 0: + elif len(edge.unpassed_data) != 0: return False + elif len(edge.unpassed_data) != 0: + return False return True diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index d30cfba28..77447eee5 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -203,24 +203,12 @@ def unpassed_data(self): @property def unsent_data(self): - from .node import SensorFusionNode, SensorNode, FusionNode, RepeaterNode, Node """Data held by the sender that has not been sent to the recipient.""" unsent = [] if isinstance(type(self.sender.data_held), type(None)) or self.sender.data_held is None: return unsent else: - if isinstance(self.nodes[0], (SensorFusionNode, Node)): - statuses = ["fused", "created"] - elif isinstance(self.nodes[0], FusionNode): - statuses = ["fused"] - elif isinstance(self.nodes[0], SensorNode): - statuses = ["created"] - elif isinstance(self.nodes[0], (RepeaterNode, Node)): - statuses = [] - else: - raise NotImplementedError("Node should be a Sensor, Fusion or SensorFusion node.") - - for status in statuses: + for status in ["fused", "created"]: for time_pertaining in self.sender.data_held[status]: for data_piece in self.sender.data_held[status][time_pertaining]: # Data will be sent to any nodes it hasn't been sent to before @@ -259,7 +247,9 @@ def remove(self, edge): self.edges.remove(edge) def get(self, node_pair): - if not isinstance(node_pair, Tuple) and all(isinstance(node, Node) for node in node_pair): + from .node import Node + if not (isinstance(node_pair, Tuple) and + all(isinstance(node, Node) for node in node_pair)): raise TypeError("Must supply a tuple of nodes") if not len(node_pair) == 2: raise ValueError("Incorrect tuple length. Must be of length 2") diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index 413d44c76..6df48f851 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -153,7 +153,7 @@ def _assign_nodes(self, node_labels): latency=0) nodes[architecture][label] = node - elif label.startswith('r'): + else: for architecture in range(self.n_archs): node = RepeaterNode(label=label, latency=0) @@ -198,7 +198,6 @@ def _generate_edgelist(self): g = nx.DiGraph(edges) for f_node in ['f' + str(i) for i in range(self.n_fusion_nodes)]: if g.in_degree(f_node) == 0: - valid = False break else: valid = True @@ -227,11 +226,14 @@ def _generate_edgelist(self): g = nx.DiGraph(edges) for f_node in ['f' + str(i) for i in range(self.n_fusion_nodes)]: if g.in_degree(f_node) == 0: - valid = False break else: valid = True + else: + raise ValueError(f"Invalid architecture type of {self.arch_type}. arch_type must be " + "one of: 'hierarchical' or 'decentralised'") + return edges, nodes diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index db70186c6..e530dd54d 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -54,7 +54,7 @@ def nodes(): node_a = Node(label="node a") node_b = Node(label="node b") - sensornode_1 = SensorNode(sensor=hmm_sensor, label="s1") + sensornode_1 = SensorNode(sensor=hmm_sensor, label="s1", font_size=10, node_dim=(1, 1)) sensornode_2 = SensorNode(sensor=hmm_sensor, label='s2') sensornode_3 = SensorNode(sensor=hmm_sensor, label='s3') sensornode_4 = SensorNode(sensor=hmm_sensor, label='s4') diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 5a7261d0a..72db7bf91 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -7,7 +7,7 @@ NonPropagatingArchitecture from ..edge import Edge, Edges, FusionQueue, Message, DataPiece from ..generator import NetworkArchitectureGenerator -from ..node import RepeaterNode, SensorNode, FusionNode +from ..node import RepeaterNode, SensorNode, FusionNode, Node from stonesoup.types.detection import TrueDetection from ...types.track import Track @@ -330,6 +330,14 @@ def test_number_of_leaves(nodes, edge_lists): assert circular_architecture.number_of_leaves(nodes['s4']) == 0 assert circular_architecture.number_of_leaves(nodes['s5']) == 0 + # Test loop case + r1 = Node() + + edges = Edges([Edge((r1, r1))]) + arch = InformationArchitecture(edges) + + assert arch.number_of_leaves(r1) == 0 + def test_leaf_nodes(nodes, edge_lists): simple_edges = edge_lists["simple_edges"] @@ -677,6 +685,36 @@ def test_network_arch(radar_sensors, ground_truths, tracker, track_tracker, time assert network_arch.information_arch.fusion_nodes == {node_C, node_F, node_G} +def test_network_arch_instantiation_methods(radar_nodes, times): + time = times['start'] + + nodeA = radar_nodes['a'] + nodeB = radar_nodes['c'] + nodeR = RepeaterNode() + + info_edges = Edges([Edge((nodeA, nodeB))]) + network_edges = Edges([Edge((nodeA, nodeR)), Edge((nodeR, nodeB))]) + + # Method 1: Provide InformationArchitecture to NetworkArchitecture + i_arch = InformationArchitecture(edges=info_edges, current_time=time) + + net_arch1 = NetworkArchitecture(edges=network_edges, information_arch=i_arch) + + assert net_arch1.information_arch.edges == info_edges + assert net_arch1.edges == network_edges + + # Method 2: Provide set of information architecture edges to NetworkArchitecture + net_arch2 = NetworkArchitecture(edges=network_edges, + information_architecture_edges=info_edges) + assert net_arch2.information_arch.edges == info_edges + assert net_arch2.edges == network_edges + + # Method 3: Identical Information and Network Architectures + net_arch3 = NetworkArchitecture(edges=info_edges) + assert net_arch3.information_arch.edges == info_edges + assert net_arch3.edges == info_edges + + def test_net_arch_fully_propagated(generator_params, ground_truths): start_time = generator_params['start_time'] base_sensor = generator_params['base_sensor'] @@ -688,7 +726,8 @@ def test_net_arch_fully_propagated(generator_params, ground_truths): node_ratio=[3, 2, 1], base_tracker=base_tracker, base_sensor=base_sensor, - n_archs=1) + n_archs=1, + sensor_max_distance=(10, 10)) arch = gen.generate()[0] diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 77254ca1a..579ec96af 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -290,6 +290,9 @@ def test_get(): with pytest.raises(ValueError): edges.get(node_pair=(node1, node2, node3)) + with pytest.raises(TypeError): + edges.get(node_pair=[2, node3]) + def test_pass_message(times): start_time = times['start'] diff --git a/stonesoup/architecture/tests/test_generator.py b/stonesoup/architecture/tests/test_generator.py index 2fbba3db2..1c06263c9 100644 --- a/stonesoup/architecture/tests/test_generator.py +++ b/stonesoup/architecture/tests/test_generator.py @@ -117,6 +117,25 @@ def test_info_generate_decentralised(generator_params): assert isinstance(node.sensor, Sensor) +def test_info_generate_invalid(generator_params): + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + + mean_deg = 2.5 + + gen = InformationArchitectureGenerator(arch_type='invalid', + start_time=start_time, + mean_degree=mean_deg, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=2) + + with pytest.raises(ValueError): + gen.generate() + + def test_net_arch_gen_init(generator_params): start_time = generator_params['start_time'] base_sensor = generator_params['base_sensor'] From 8c794b546bebe0c2231379a97dc5b6fde2bb6619 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 6 Sep 2024 09:15:28 +0100 Subject: [PATCH 144/170] improve coverage --- stonesoup/architecture/__init__.py | 4 ---- stonesoup/architecture/tests/conftest.py | 2 +- stonesoup/architecture/tests/test_edge.py | 16 ++++++++++++++++ stonesoup/architecture/tests/test_generator.py | 7 ++----- stonesoup/architecture/tests/test_node.py | 4 ++++ 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 2e4a55691..f55493a17 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -516,10 +516,6 @@ def __init__(self, *args, **kwargs): node_label_gens = {} labels = {node.label.replace("\n", " ") for node in self.di_graph.nodes if node.label} for node in self.di_graph.nodes: - if not node.label: - label_gen = node_label_gens.setdefault(type(node), _default_label_gen(type(node))) - while not node.label or node.label.replace("\n", " ") in labels: - node.label = next(label_gen) self.di_graph.nodes[node].update(self._node_kwargs(node)) def measure(self, ground_truths: List[GroundTruthPath], noise: Union[bool, np.ndarray] = True, diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index e530dd54d..2d813e634 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -55,7 +55,7 @@ def nodes(): node_a = Node(label="node a") node_b = Node(label="node b") sensornode_1 = SensorNode(sensor=hmm_sensor, label="s1", font_size=10, node_dim=(1, 1)) - sensornode_2 = SensorNode(sensor=hmm_sensor, label='s2') + sensornode_2 = SensorNode(sensor=hmm_sensor) sensornode_3 = SensorNode(sensor=hmm_sensor, label='s3') sensornode_4 = SensorNode(sensor=hmm_sensor, label='s4') sensornode_5 = SensorNode(sensor=hmm_sensor, label='s5') diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 579ec96af..81a012bde 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -141,6 +141,9 @@ def test_fusion_queue(): assert b == "another item" assert q._to_consume == 1 + # with pytest.raises(RuntimeError): + # next(next(iter_q)) + def test_message_destinations(times, radar_nodes): start_time = times['start'] @@ -173,6 +176,11 @@ def test_message_destinations(times, radar_nodes): DataPiece(node1, node1, Track([]), datetime.datetime(2016, 1, 2, 3, 4, 5)), destinations={node2, node3}) + + # Another message like 1, but this will not be put through Edge.pass_message + message1b = Message(edge1, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, + DataPiece(node1, node1, Track([]), + datetime.datetime(2016, 1, 2, 3, 4, 5))) # Add messages to node1.messages_to_pass_on and check that unpassed_data() catches it node1.messages_to_pass_on = [message1, message2, message3, message4] @@ -196,10 +204,16 @@ def test_message_destinations(times, radar_nodes): assert node2.messages_to_pass_on == [] assert node3.messages_to_pass_on == [] + # Add message without destination to edge1 + edge1.unpassed_data.append(message1b) + assert message1b.destinations is None + # Update both edges edge1.update_messages(start_time+datetime.timedelta(minutes=1), to_network_node=False) edge2.update_messages(start_time + datetime.timedelta(minutes=1), to_network_node=True) + assert message1b.destinations == {edge1.recipient} + # Check node2.messages_to_pass_on contains message3 that does not have node 2 as a destination assert len(node2.messages_to_pass_on) == 2 # Check node3.messages_to_pass_on contains all messages as it is not in information arch @@ -212,6 +226,8 @@ def test_message_destinations(times, radar_nodes): assert len(data_held) == 3 + + def test_unpassed_data(times): start_time = times['start'] node1 = RepeaterNode() diff --git a/stonesoup/architecture/tests/test_generator.py b/stonesoup/architecture/tests/test_generator.py index 1c06263c9..17cc03b59 100644 --- a/stonesoup/architecture/tests/test_generator.py +++ b/stonesoup/architecture/tests/test_generator.py @@ -124,7 +124,8 @@ def test_info_generate_invalid(generator_params): mean_deg = 2.5 - gen = InformationArchitectureGenerator(arch_type='invalid', + with pytest.raises(ValueError): + gen = InformationArchitectureGenerator(arch_type='invalid', start_time=start_time, mean_degree=mean_deg, node_ratio=[3, 1, 1], @@ -132,10 +133,6 @@ def test_info_generate_invalid(generator_params): base_sensor=base_sensor, n_archs=2) - with pytest.raises(ValueError): - gen.generate() - - def test_net_arch_gen_init(generator_params): start_time = generator_params['start_time'] base_sensor = generator_params['base_sensor'] diff --git a/stonesoup/architecture/tests/test_node.py b/stonesoup/architecture/tests/test_node.py index e6d64adf8..083ec07a3 100644 --- a/stonesoup/architecture/tests/test_node.py +++ b/stonesoup/architecture/tests/test_node.py @@ -31,6 +31,10 @@ def test_node(data_pieces, times, nodes): assert isinstance(new_data_piece2.data, Hypothesis) assert new_data_piece2.time_arrived == times['a'] + with pytest.raises(TypeError): + node.update(times['a'], times['b'], data_pieces['fail'], "fused", track=Track([]), + use_arrival_time=False) + def test_sensor_node(nodes): with pytest.raises(TypeError): From b511737e10404af7944005c734685b52418c8ac7 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 6 Sep 2024 10:29:17 +0100 Subject: [PATCH 145/170] fix error in test_edge.py --- stonesoup/architecture/tests/test_edge.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 81a012bde..8d91eb8e2 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -178,7 +178,7 @@ def test_message_destinations(times, radar_nodes): destinations={node2, node3}) # Another message like 1, but this will not be put through Edge.pass_message - message1b = Message(edge1, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, + message1b = Message(edge1, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time+timedelta(seconds=1), DataPiece(node1, node1, Track([]), datetime.datetime(2016, 1, 2, 3, 4, 5))) @@ -212,7 +212,8 @@ def test_message_destinations(times, radar_nodes): edge1.update_messages(start_time+datetime.timedelta(minutes=1), to_network_node=False) edge2.update_messages(start_time + datetime.timedelta(minutes=1), to_network_node=True) - assert message1b.destinations == {edge1.recipient} + m1b = {m for m in edge1.messages_held['pending'][start_time+timedelta(seconds=1)]}.pop() + assert m1b == {edge1.recipient} # Check node2.messages_to_pass_on contains message3 that does not have node 2 as a destination assert len(node2.messages_to_pass_on) == 2 From ffd7fc1db6bfaf36dfc3b917c667ac7bd0fcfb96 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 9 Sep 2024 09:18:04 +0100 Subject: [PATCH 146/170] Fix to test_message_destinations. Add test_update_messages() to test extra scenarios. --- stonesoup/architecture/__init__.py | 2 - stonesoup/architecture/generator.py | 6 +-- stonesoup/architecture/tests/test_edge.py | 53 +++++++++++++------ .../architecture/tests/test_generator.py | 28 +++++----- stonesoup/architecture/tests/test_node.py | 8 ++- 5 files changed, 60 insertions(+), 37 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index f55493a17..8518fd4af 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -513,8 +513,6 @@ def __init__(self, *args, **kwargs): # Need to reset digraph for info-arch self.di_graph = nx.to_networkx_graph(self.edges.edge_list, create_using=nx.DiGraph) # Set attributes such as label, colour, shape, etc. for each node - node_label_gens = {} - labels = {node.label.replace("\n", " ") for node in self.di_graph.nodes if node.label} for node in self.di_graph.nodes: self.di_graph.nodes[node].update(self._node_kwargs(node)) diff --git a/stonesoup/architecture/generator.py b/stonesoup/architecture/generator.py index 6df48f851..f76239efb 100644 --- a/stonesoup/architecture/generator.py +++ b/stonesoup/architecture/generator.py @@ -202,7 +202,7 @@ def _generate_edgelist(self): else: valid = True - elif self.arch_type == 'decentralised': + else: while not valid: @@ -230,10 +230,6 @@ def _generate_edgelist(self): else: valid = True - else: - raise ValueError(f"Invalid architecture type of {self.arch_type}. arch_type must be " - "one of: 'hierarchical' or 'decentralised'") - return edges, nodes diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 8d91eb8e2..0a5638af3 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -2,10 +2,11 @@ import pytest -from .. import RepeaterNode +from .. import RepeaterNode, Node from ..edge import Edges, Edge, DataPiece, Message, FusionQueue from ...types.track import Track from ...types.time import CompoundTimeRange, TimeRange +from .._functions import _dict_set from datetime import timedelta @@ -69,6 +70,42 @@ def test_send_update_message(edges, times, data_pieces): assert message in edge.messages_held['received'][times['a']] +def test_update_messages(): + + # Test scenario where message has not yet reached recipient + A = Node(label='A') + B = Node(label='B') + edge = Edge((A, B), edge_latency=0.5) + + time_created = datetime.datetime.now() + time_sent = time_created + data = DataPiece(A, A, Track([]), time_created) + + # Add message to edge + edge.send_message(data, time_created, time_sent) + assert len(edge.messages_held['pending']) == 1 + + # Message should not have arrived yet + edge.update_messages(time_created) + assert len(edge.messages_held['pending']) == 1 + + # Try again a secomd later + edge.update_messages(time_created + timedelta(seconds=1)) + assert len(edge.messages_held['pending']) == 0 + + # Test scenario when message has no destinations + message = Message(edge, time_created, time_sent, data, destinations=None) + _, edge.messages_held = _dict_set(edge.messages_held, + message, + 'pending', + message.arrival_time) + + assert message in edge.messages_held['pending'][message.arrival_time] + + edge.update_messages(time_created + datetime.timedelta(seconds=2)) + assert len(edge.messages_held['pending']) == 0 + + def test_failed(edges, times): edge = edges['a'] assert edge.time_range_failed == CompoundTimeRange() @@ -176,11 +213,6 @@ def test_message_destinations(times, radar_nodes): DataPiece(node1, node1, Track([]), datetime.datetime(2016, 1, 2, 3, 4, 5)), destinations={node2, node3}) - - # Another message like 1, but this will not be put through Edge.pass_message - message1b = Message(edge1, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time+timedelta(seconds=1), - DataPiece(node1, node1, Track([]), - datetime.datetime(2016, 1, 2, 3, 4, 5))) # Add messages to node1.messages_to_pass_on and check that unpassed_data() catches it node1.messages_to_pass_on = [message1, message2, message3, message4] @@ -204,17 +236,10 @@ def test_message_destinations(times, radar_nodes): assert node2.messages_to_pass_on == [] assert node3.messages_to_pass_on == [] - # Add message without destination to edge1 - edge1.unpassed_data.append(message1b) - assert message1b.destinations is None - # Update both edges edge1.update_messages(start_time+datetime.timedelta(minutes=1), to_network_node=False) edge2.update_messages(start_time + datetime.timedelta(minutes=1), to_network_node=True) - m1b = {m for m in edge1.messages_held['pending'][start_time+timedelta(seconds=1)]}.pop() - assert m1b == {edge1.recipient} - # Check node2.messages_to_pass_on contains message3 that does not have node 2 as a destination assert len(node2.messages_to_pass_on) == 2 # Check node3.messages_to_pass_on contains all messages as it is not in information arch @@ -227,8 +252,6 @@ def test_message_destinations(times, radar_nodes): assert len(data_held) == 3 - - def test_unpassed_data(times): start_time = times['start'] node1 = RepeaterNode() diff --git a/stonesoup/architecture/tests/test_generator.py b/stonesoup/architecture/tests/test_generator.py index 17cc03b59..b936d5eda 100644 --- a/stonesoup/architecture/tests/test_generator.py +++ b/stonesoup/architecture/tests/test_generator.py @@ -125,13 +125,14 @@ def test_info_generate_invalid(generator_params): mean_deg = 2.5 with pytest.raises(ValueError): - gen = InformationArchitectureGenerator(arch_type='invalid', - start_time=start_time, - mean_degree=mean_deg, - node_ratio=[3, 1, 1], - base_tracker=base_tracker, - base_sensor=base_sensor, - n_archs=2) + InformationArchitectureGenerator(arch_type='invalid', + start_time=start_time, + mean_degree=mean_deg, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=2) + def test_net_arch_gen_init(generator_params): start_time = generator_params['start_time'] @@ -158,12 +159,13 @@ def test_net_arch_gen_init(generator_params): assert gen.sensor_max_distance == (0, 0) with pytest.raises(ValueError): - NetworkArchitectureGenerator(arch_type='not_valid', - start_time=start_time, - mean_degree=2, - node_ratio=[3, 1, 1], - base_tracker=base_tracker, - base_sensor=base_sensor) + gen = NetworkArchitectureGenerator(arch_type='not_valid', + start_time=start_time, + mean_degree=2, + node_ratio=[3, 1, 1], + base_tracker=base_tracker, + base_sensor=base_sensor) + gen.generate() def test_net_generate_hierarchical(generator_params): diff --git a/stonesoup/architecture/tests/test_node.py b/stonesoup/architecture/tests/test_node.py index 083ec07a3..0dbf52317 100644 --- a/stonesoup/architecture/tests/test_node.py +++ b/stonesoup/architecture/tests/test_node.py @@ -32,8 +32,12 @@ def test_node(data_pieces, times, nodes): assert new_data_piece2.time_arrived == times['a'] with pytest.raises(TypeError): - node.update(times['a'], times['b'], data_pieces['fail'], "fused", track=Track([]), - use_arrival_time=False) + node.update(times['a'], + times['b'], + data_pieces['fail'], + "fused", + track=Track([]), + use_arrival_time=False) def test_sensor_node(nodes): From 35e814c584db0e11d9c093d0862b9c09564ea6d3 Mon Sep 17 00:00:00 2001 From: spike Date: Mon, 9 Sep 2024 16:27:58 +0100 Subject: [PATCH 147/170] Improve coverage of architecture.node. --- stonesoup/architecture/node.py | 25 +++-- stonesoup/architecture/tests/conftest.py | 4 +- stonesoup/architecture/tests/test_node.py | 119 +++++++++++++++++++++- 3 files changed, 136 insertions(+), 12 deletions(-) diff --git a/stonesoup/architecture/node.py b/stonesoup/architecture/node.py index ed021999c..cd77b8881 100644 --- a/stonesoup/architecture/node.py +++ b/stonesoup/architecture/node.py @@ -49,8 +49,12 @@ def __init__(self, *args, **kwargs): def update(self, time_pertaining, time_arrived, data_piece, category, track=None, use_arrival_time=False): """Updates this :class:`~.Node`'s :attr:`~.data_held` using a new data piece. """ - if not isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime): + if not (isinstance(time_pertaining, datetime) and isinstance(time_arrived, datetime)): raise TypeError("Times must be datetime objects") + if not isinstance(data_piece, DataPiece): + raise TypeError(f"data_piece must be a DataPiece. Provided type {type(data_piece)}") + if category not in self.data_held.keys(): + raise ValueError(f"category must be one of {self.data_held.keys()}") if not track: if not isinstance(data_piece.data, Detection) and \ not isinstance(data_piece.data, Track): @@ -75,10 +79,11 @@ def update(self, time_pertaining, time_arrived, data_piece, category, track=None self.fusion_queue.received.add(data) self.fusion_queue.put((time_pertaining, {data})) - elif isinstance(self, FusionNode) and category in ("created", "unfused"): - if data_piece.data not in self.fusion_queue.received: - self.fusion_queue.received.add(data_piece.data) - self.fusion_queue.put((time_pertaining, {data_piece.data})) + elif isinstance(self, FusionNode) and \ + category in ("created", "unfused") and \ + data_piece.data not in self.fusion_queue.received: + self.fusion_queue.received.add(data_piece.data) + self.fusion_queue.put((time_pertaining, {data_piece.data})) return added @@ -116,7 +121,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.tracks = set() # Set of tracks this Node has recorded if not self.fusion_queue: - self.fusion_queue = FusionQueue() + if self.tracker.detector: + self.fusion_queue = self.tracker.detector + else: + self.fusion_queue = FusionQueue() + self.tracker.detector = self.fusion_queue self._track_queue = Queue() self._tracking_thread = threading.Thread( @@ -141,9 +150,9 @@ def fuse(self): if not self._tracking_thread.is_alive() or waiting_for_data: break else: - time, tracks = data + _, tracks = data self.tracks.update(tracks) - updated_tracks |= tracks + updated_tracks = updated_tracks.union(tracks) for track in updated_tracks: data_piece = DataPiece(self, self, copy.copy(track), track.timestamp, True) diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 2d813e634..71cb89832 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -114,13 +114,11 @@ def timesteps(times): @pytest.fixture -def ground_truths(transition_model, times): - start_time = times["start"] +def ground_truths(transition_model, times, timesteps): yps = range(0, 100, 10) # y value for prior state truths = OrderedSet() ntruths = 3 # number of ground truths in simulation time_max = 60 # timestamps the simulation is observed over - timesteps = [start_time + timedelta(seconds=k) for k in range(time_max)] xdirection = 1 ydirection = 1 diff --git a/stonesoup/architecture/tests/test_node.py b/stonesoup/architecture/tests/test_node.py index 0dbf52317..ed7038c83 100644 --- a/stonesoup/architecture/tests/test_node.py +++ b/stonesoup/architecture/tests/test_node.py @@ -1,11 +1,16 @@ import pytest import copy +from datetime import datetime +import numpy as np from ..node import Node, SensorNode, FusionNode, SensorFusionNode, RepeaterNode -from ..edge import FusionQueue +from ..edge import FusionQueue, DataPiece +from ..generator import NetworkArchitectureGenerator from ...types.hypothesis import Hypothesis from ...types.track import Track +from ...types.detection import Detection +from ... types.groundtruth import GroundTruthPath from ...types.state import State, StateVector @@ -65,6 +70,20 @@ def test_fusion_node(tracker): fnode.fuse() # Works. Thorough testing left to test_architecture.py + # Test FusionNode instantiation with no fusion_queue + fnode2_tracker = copy.deepcopy(tracker) + fnode2_tracker.detector = FusionQueue() + fnode2 = FusionNode(fnode2_tracker) + + assert fnode2.fusion_queue == fnode2_tracker.detector + + # Test FusionNode instantiation with no fusion_queue or tracker.detector + fnode3_tracker = copy.deepcopy(tracker) + fnode3_tracker.detector = None + fnode3 = FusionNode(fnode3_tracker) + + assert isinstance(fnode3.fusion_queue, FusionQueue) + def test_sf_node(tracker, nodes): with pytest.raises(TypeError): @@ -85,3 +104,101 @@ def test_repeater_node(): assert rnode.colour == '#909090' assert rnode.shape == 'rectangle' + + +def test_update(tracker): + A = Node() + B = Node() + C = Node() + + dt0 = "This ain't no datetime object" + dt1 = datetime.now() + + t_data = DataPiece(A, A, Track([]), dt1) + d_data = DataPiece(A, A, Detection(state_vector=StateVector(np.random.rand(4, 1)), + timestamp=dt1), dt1) + h_data = DataPiece(A, A, Hypothesis(), dt1) + + # Test invalid time inputs + with pytest.raises(TypeError): + A.update(dt0, dt0, 'faux DataPiece', 'created') + + # Test invalid data_piece + with pytest.raises(TypeError): + A.update(dt1, dt1, 'faux DataPiece', 'created') + + # Test invalid category + with pytest.raises(ValueError): + A.update(dt1, dt1, t_data, 'forged') + + # Test non-detection-or-track-datapiece with Track=False + with pytest.raises(TypeError): + A.update(dt1, dt1, h_data, 'created') + + # Test non-hypothesis-datapiece with Track=True + with pytest.raises(TypeError): + A.update(dt1, dt1, d_data, 'created', track=True) + + # For track DataPiece, test new DataPiece is created and placed in data_held + A.update(dt1, dt1, t_data, 'created') + new_data_piece = A.data_held['created'][dt1].pop() + + assert t_data.originator == new_data_piece.originator + assert t_data.data == new_data_piece.data + assert t_data.time_arrived == new_data_piece.time_arrived + + # For detection DataPiece, test new DataPiece is created and placed in data_held + B.update(dt1, dt1, d_data, 'created') + new_data_piece = B.data_held['created'][dt1].pop() + + assert d_data.originator == new_data_piece.originator + assert d_data.data == new_data_piece.data + assert d_data.time_arrived == new_data_piece.time_arrived + + # For hypothesis DataPiece, test new DataPiece is created and placed in data_held + C.update(dt1, dt1, h_data, 'created', track=True) + new_data_piece = C.data_held['created'][dt1].pop() + + assert h_data.originator == new_data_piece.originator + assert h_data.data == new_data_piece.data + assert h_data.time_arrived == new_data_piece.time_arrived + + # Test placing data into fusion queue - use_arrival_time=False + D = FusionNode(tracker=tracker) + D.update(dt1, dt1, d_data, 'created', use_arrival_time=False) + assert d_data.data in D.fusion_queue.received + + # Test placing data into fusion queue - use_arrival_time=True + D = FusionNode(tracker=tracker) + D.update(dt1, dt1, d_data, 'created', use_arrival_time=True) + copied_data = D.fusion_queue.received.pop() + assert sum(copied_data.state_vector - d_data.data.state_vector) == 0 + assert copied_data.measurement_model == d_data.data.measurement_model + assert copied_data.metadata == d_data.data.metadata + + +def test_fuse(generator_params, ground_truths, timesteps): + # Full data fusion simulation + start_time = generator_params['start_time'] + base_sensor = generator_params['base_sensor'] + base_tracker = generator_params['base_tracker'] + gen = NetworkArchitectureGenerator(arch_type='hierarchical', + start_time=start_time, + mean_degree=2, + node_ratio=[3, 2, 1], + base_tracker=base_tracker, + base_sensor=base_sensor, + n_archs=1, + sensor_max_distance=(10, 10)) + + arch = gen.generate()[0] + + assert all([isinstance(gt, GroundTruthPath) for gt in ground_truths]) + + for time in timesteps: + arch.measure(ground_truths, noise=True) + arch.propagate(time_increment=1) + + for node in arch.fusion_nodes: + for track in node.tracks: + assert isinstance(track, Track) From bd622b8924c9ac3e3cb6d89ae7250c84025e98f0 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 9 Sep 2024 18:05:02 +0100 Subject: [PATCH 148/170] remove unnecessary RuntimeError check in FusionQueue --- stonesoup/architecture/edge.py | 6 ------ stonesoup/architecture/tests/test_edge.py | 3 --- 2 files changed, 9 deletions(-) diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 77447eee5..68ee80dec 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -32,8 +32,6 @@ def _put(self, *args, **kwargs): self._to_consume += 1 def __iter__(self): - if self._consuming: - raise RuntimeError("Queue can only be iterated over once.") self._consuming = True while True: yield super().get() @@ -96,10 +94,6 @@ def send_message(self, data_piece, time_pertaining, time_sent): """ if not isinstance(data_piece, DataPiece): raise TypeError(f"data_piece is type {type(data_piece)}. Expected DataPiece") - # Add message to 'pending' dict of edge - # data_to_send = copy.deepcopy(data_piece.data) - # new_datapiece = DataPiece(data_piece.node, data_piece.originator, data_to_send, - # data_piece.time_arrived, data_piece.track) message = Message(edge=self, time_pertaining=time_pertaining, time_sent=time_sent, data_piece=data_piece, destinations={self.recipient}) _, self.messages_held = _dict_set(self.messages_held, message, 'pending', time_sent) diff --git a/stonesoup/architecture/tests/test_edge.py b/stonesoup/architecture/tests/test_edge.py index 0a5638af3..eae7b4748 100644 --- a/stonesoup/architecture/tests/test_edge.py +++ b/stonesoup/architecture/tests/test_edge.py @@ -178,9 +178,6 @@ def test_fusion_queue(): assert b == "another item" assert q._to_consume == 1 - # with pytest.raises(RuntimeError): - # next(next(iter_q)) - def test_message_destinations(times, radar_nodes): start_time = times['start'] From c334831dacff7f6d42315cb90021f071ad872d9c Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Mon, 16 Sep 2024 14:34:42 +0100 Subject: [PATCH 149/170] Fix architecture tests --- stonesoup/architecture/tests/conftest.py | 14 ++++++------- .../architecture/tests/test_architecture.py | 20 ++++++++++++++----- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/stonesoup/architecture/tests/conftest.py b/stonesoup/architecture/tests/conftest.py index 71cb89832..8cbd0be3e 100644 --- a/stonesoup/architecture/tests/conftest.py +++ b/stonesoup/architecture/tests/conftest.py @@ -168,13 +168,13 @@ def radar_sensors(times): @pytest.fixture -def predictor(): +def predictor(transition_model): predictor = KalmanPredictor(transition_model) return predictor @pytest.fixture -def updater(transition_model): +def updater(): updater = ExtendedKalmanUpdater(measurement_model=None) return updater @@ -199,7 +199,7 @@ def deleter(hypothesiser): @pytest.fixture -def initiator(): +def initiator(data_associator, deleter, updater): initiator = MultiMeasurementInitiator( prior_state=GaussianState([[0], [0], [0], [0]], np.diag([0, 1, 0, 1])), measurement_model=None, @@ -212,7 +212,7 @@ def initiator(): @pytest.fixture -def tracker(): +def tracker(initiator, deleter, data_associator, updater): tracker = MultiTargetTracker(initiator, deleter, None, data_associator, updater) return tracker @@ -230,7 +230,7 @@ def detection_updater(): @pytest.fixture -def detection_track_updater(): +def detection_track_updater(detection_updater, track_updater): detection_track_updater = DetectionAndTrackSwitchingUpdater(None, detection_updater, track_updater) return detection_track_updater @@ -243,7 +243,7 @@ def fusion_queue(): @pytest.fixture -def track_tracker(): +def track_tracker(initiator, deleter, fusion_queue, data_associator, detection_track_updater): track_tracker = MultiTargetTracker( initiator, deleter, Tracks2GaussianDetectionFeeder(fusion_queue), data_associator, detection_track_updater) @@ -251,7 +251,7 @@ def track_tracker(): @pytest.fixture -def radar_nodes(radar_sensors, fusion_queue): +def radar_nodes(tracker, track_tracker, radar_sensors, fusion_queue): sensor_set = radar_sensors node_A = SensorNode(sensor=sensor_set[0]) node_B = SensorNode(sensor=sensor_set[2]) diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 72db7bf91..03ec6810c 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -2,13 +2,15 @@ import pytest import datetime +import numpy as np -from stonesoup.architecture import InformationArchitecture, NetworkArchitecture, \ +from .. import InformationArchitecture, NetworkArchitecture, \ NonPropagatingArchitecture from ..edge import Edge, Edges, FusionQueue, Message, DataPiece from ..generator import NetworkArchitectureGenerator from ..node import RepeaterNode, SensorNode, FusionNode, Node -from stonesoup.types.detection import TrueDetection +from ...types.detection import TrueDetection +from ...types.state import GaussianState from ...types.track import Track @@ -748,9 +750,17 @@ def test_net_arch_fully_propagated(generator_params, ground_truths): isinstance(edge.nodes[0], RepeaterNode) and isinstance(edge.nodes[1], FusionNode)}.pop() - message = Message(edge, datetime.datetime(2016, 1, 2, 3, 4, 5), start_time, - DataPiece(edge.sender, edge.sender, Track([]), - datetime.datetime(2016, 1, 2, 3, 4, 5))) + message = Message( + edge, + datetime.datetime(2016, 1, 2, 3, 4, 5), + start_time, + DataPiece( + edge.sender, + edge.sender, + Track([GaussianState([1, 2, 3, 4], np.diag([1, 1, 1, 1]), datetime.datetime(2016, 1, 2, 3, 4, 5))]), + datetime.datetime(2016, 1, 2, 3, 4, 5), + ), + ) edge.sender.messages_to_pass_on.append(message) From a4e16d374b37c50cb55a41d634a152b0830aa49b Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 16 Sep 2024 19:21:30 +0100 Subject: [PATCH 150/170] fix flake8 and tutorial prose --- .../01_Introduction_to_Architectures.py | 38 ++++++++------ ...2_Information_and_Network_Architectures.py | 49 ++++++++++--------- .../architecture/03_Avoiding_Data_Incest.py | 19 ++++--- .../architecture/tests/test_architecture.py | 3 +- 4 files changed, 59 insertions(+), 50 deletions(-) diff --git a/docs/tutorials/architecture/01_Introduction_to_Architectures.py b/docs/tutorials/architecture/01_Introduction_to_Architectures.py index b45414bf7..aa28f0ad8 100644 --- a/docs/tutorials/architecture/01_Introduction_to_Architectures.py +++ b/docs/tutorials/architecture/01_Introduction_to_Architectures.py @@ -11,7 +11,7 @@ # Introduction # ------------ # -# The architecture package in stonesoup provides functionality to build Information and Network +# The architecture package in Stone Soup provides functionality to build information and network # architectures, enabling the user to simulate sensing, propagation and fusion of data. # Architectures are modelled by defining the nodes in the architecture, and edges that represent # connections between nodes. @@ -19,11 +19,11 @@ # Nodes # ----- # -# Nodes represent points in the architecture that process the data in some way. Before advancing, +# Nodes represent points in the architecture that collect, process (fuse), or simply forward on data. Before advancing, # a few definitions are required: # # - Relationships between nodes are defined as parent-child. In a directed graph, an edge from -# node A to node B informs that data is passed from the child node, A, to the parent node, B. +# node A to node B means that data is passed from the child node, A, to the parent node, B. # # - The children of node A, denoted :math:`children(A)`, is defined as the set of nodes B, where # there exists a direct edge from node B to node A (set of nodes that A receives data from). @@ -32,7 +32,7 @@ # there exists a direct edge from node A to node B (set of nodes that A passes data to). # # Different types of node can provide different functionality in the architecture. The following -# are available in stonesoup: +# are available in Stone Soup: # # - :class:`.~SensorNode`: makes detections of targets and propagates data onwards through the # architecture. @@ -42,8 +42,8 @@ # # - :class:`.~SensorFusionNode`: has the functionality of both a SensorNode and a FusionNode. # -# - :class:`.~RepeaterNode`: carries out no processing of data. Propagates data forwards that it -# has received. Cannot be used in information architecture. +# - :class:`.~RepeaterNode`: does not create or fuse data, but only propagates it onwards. +# It is only used in network architectures. # # Set up and Node Properties # ^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -55,9 +55,9 @@ node_C = Node(label='Node C') # %% -# The Node base class contains several properties. The `latency` property gives functionality to -# simulate processing latency at the node. The rest of the properties (label, position, colour, -# shape, font_size, node_dim), are used for graph plotting. +# The :class:`.~Node` base class contains several properties. The `latency` property gives functionality to +# simulate processing latency at the node. The rest of the properties (`label`, `position`, `colour`, +# `shape`, `font_size`, `node_dim`), are used for graph plotting. node_A.colour = '#006494' @@ -67,17 +67,18 @@ # :class:`~.SensorNode` and :class:`~.FusionNode` objects have additional properties that must be # defined. A :class:`~.SensorNode` must be given an additional `sensor` property - this must be a # :class:`~.Sensor`. A :class:`~.FusionNode` has two additional properties: `tracker` and -# `fusion_queue`. `tracker` must be a :class:`~.Tracker` - the tracker manages the fusion at -# the node. The `fusion_queue` property is a :class:`~.FusionQueue` by default - this manages the +# `fusion_queue`.`tracker` must both be :class:`~.Tracker`\s - the main tracker manages the fusion at +# the node, while the `fusion_queue` property is a :class:`~.FusionQueue` by default - this manages the # inflow of data from child nodes. # # Edges # ----- # An edge represents a link between two nodes in an architecture. An :class:`~.Edge` contains a # property `nodes`: a tuple of :class:`~.Node` objects where the first entry in the tuple is -# the child node and the second is the parent. Edges in stonesoup are directional (data can +# the child node and the second is the parent. Edges in Stone Soup are directional (data can # flow only in one direction), with data flowing from child to parent. Edge objects also -# contain a `latency` property to enable simulation of latency caused by sending a message. +# contain a `latency` property to enable simulation of latency caused by sending a message, +# separately to node latency. from stonesoup.architecture.edge import Edge @@ -99,12 +100,19 @@ # ------------ # Architecture classes manage the simulation of data propagation across a network. Two # architecture classes are available in Stone Soup: :class:`~.InformationArchitecture` and -# :class:`~.NetworkArchitecture`. Information architecture simulates the architecture of how +# :class:`~.NetworkArchitecture`. Information architecture simulates how # information is shared across the network, only considering nodes that create or modify -# information. Network architecture simulates the architecture of how data is physically +# information. Network architecture simulates how data is actually # propagated through a network. All nodes are considered including nodes that don't open or modify # any data. # +# A good analogy for the two is receiving a parcel via post. The "information architecture" is +# from sender to receiver, the former who creates the parcel, and the latter who opens it. +# However, between these are many unseen but crucial steps which form the "network architecture". +# In this analogy, the postman never does anything with the parcel besides deliver it, so functions +# like a Stone Soup :class:`~.RepeaterNode`. +# +# # Architecture classes contain an `edges` property - this must be an :class:`~.Edges` object. # The `current_time` property of an Architecture instance maintains the current time within the # simulation. By default, this begins at the current time of the operating system. diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index fa85d3606..d02b1f902 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -7,11 +7,11 @@ ======================================== """ # %% -# Comparing Information and Network Architectures using ArchitectureGenerators +# Comparing Information and Network Architectures Using ArchitectureGenerators # ---------------------------------------------------------------------------- # -# In this demo, we intend to show that running a simulation over both an Information -# Architecture, and its underlying Network Architecture, yields the same results. +# In this demo, we intend to show that running a simulation over both an information +# architecture and its underlying network architecture yields the same results. # # To build this demonstration, we shall carry out the following steps: # @@ -19,13 +19,13 @@ # # 2) Build a base sensor model, and a base tracker # -# 3) Use the ArchitectureGenerator classes to generate 2 pairs of identical architectures -# (1 network, 1 information), where the network architecture is a valid representation of +# 3) Use the :class:`~.ArchitectureGenerator` classes to generate 2 pairs of identical architectures +# (one of each type), where the network architecture is a valid representation of # the information architecture. # -# 4) Run simulation over both, and compare results +# 4) Run the simulation over both, and compare results. # -# 5) Remove edges from each of the architectures, rerun and +# 5) Remove edges from each of the architectures, and rerun. # %% # Module Imports @@ -83,7 +83,7 @@ # %% # 2 - Base Tracker and Sensor Models # ---------------------------------- -# We can use the ArchitectureGenerator classes to generate multiple identical architectures. +# We can use the :class:`~.ArchitectureGenerator` classes to generate multiple identical architectures. # These classes take in base tracker and sensor models, which are duplicated and applied to each # relevant node in the architecture. The base tracker must not have a detector, in order for it # to be duplicated - the detector will be applied during the architecture generation step. @@ -118,8 +118,8 @@ # %% # Tracker # ^^^^^^^ -# The base tracker provides a similar concept to the base sensor - it is duplicated and applied -# to each fusion node. In order to duplicate the tracker, it's components must all be compatible +# The base tracker is used here in the same way as the base sensor - it is duplicated and applied +# to each fusion node. In order to duplicate the tracker, its components must all be compatible # with being deep-copied. This means that we need to remove the fusion queue and reassign it # after duplication. @@ -160,7 +160,7 @@ # %% # 3 - Generate Identical Architectures # ------------------------------------ -# The :class:`~.NetworkArchitecture` class has a property information_arch, which contains the +# The :class:`~.NetworkArchitecture` class has a property `information_arch`, which contains the # information architecture representation of the underlying network architecture. This means # that if we use the :class:`~.NetworkArchitectureGenerator` class to generate a pair of identical network # architectures, we can extract the information architecture from one. @@ -186,16 +186,16 @@ network_arch = id_net_archs[0] information_arch = id_net_archs[1].information_arch -network_arch.plot() +network_arch # %% -information_arch.plot() +information_arch # %% # The two plots above display a network architecture, and corresponding information architecture, # respectively. Grey nodes in the network architecture represent repeater nodes - these have the # sole purpose of passing data from one node to another. Comparing the two graphs, while ignoring -# the repeater nodes, should reveal that the two plots are both representations of the same +# the repeater nodes, should confirm that the two plots are both representations of the same # system. # %% @@ -253,7 +253,7 @@ def reduce_tracks(tracks): # %% # Run Information Architecture Simulation # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# Run the simulation over the information architecture. As before, we extract some extra +# Now we run the simulation over the information architecture. As before, we extract some extra # information from the architecture to add to the plot - location of sensors, and detections. @@ -283,10 +283,11 @@ def reduce_tracks(tracks): # %% # Comparing Tracks from each Architecture +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # # The information architecture we have studied is hierarchical, and while the network -# architecture isn't strictly a hierarchical graph, it does have one central node receiving all -# information. The central node is Fusion Node 1. The code below plots SIAP metrics for the +# architecture isn't strictly a hierarchical graph, it does have one central node (Fusion Node 1) +# receiving all information. The code below plots SIAP metrics for the # tracks maintained at Fusion Node 1 in both architecures. Some variation between the two is # expected due to the randomness of the measurements, but we aim to show that the results from # both architectures are near identical. @@ -347,22 +348,22 @@ def reduce_tracks(tracks): # %% from stonesoup.metricgenerator.metrictables import SIAPDiffTableGenerator -SIAPDiffTableGenerator([network_siap_averages, information_siap_averages]).compute_metric() +SIAPDiffTableGenerator([network_siap_averages, information_siap_averages]).compute_metric(); # %% # 5 - Remove edges from each architecture and re-run # -------------------------------------------------- # In this section, we take an identical copy of each of the architectures above, and remove an -# edge. We aim to show the following 2 points: +# edge. We aim to show the following: # -# 1) It is possible to remove certain edges from a network architecture without effecting the +# - It is possible to remove certain edges from a network architecture without affecting the # performance of the network. # -# 2) Removing an edge from an information architecture will likely have an effect on performance. +# - Removing an edge from an information architecture will likely have an effect on performance. # # First, we must set up the two architectures, and remove an edge from each. In the network # architecture, there are multiple routes between some pairs of nodes. This redundency increases -# the resiliance of the network when an edge, or node, is taken out of action. In this example, +# the resilience of the network when an edge, or node, is taken out of action. In this example, # we remove edges connecting repeater node r3, in turn, disabling a route from sensor node s0 # to fusion node f0. As another route from s0 to f0 exists (via repeater node r4), the # performance of the network should not be effected (assuming unlimited bandwidth). @@ -382,7 +383,7 @@ def reduce_tracks(tracks): network_arch_rm.edges.remove(edge) # %% -network_arch_rm.plot() +network_arch_rm # %% # Now we remove an edge from the information architecture. You could choose pretty much any @@ -401,7 +402,7 @@ def reduce_tracks(tracks): information_arch_rm.edges.remove(edge) # %% -information_arch_rm.plot() +information_arch_rm # %% # We now run the simulation for both architectures and calculate the same SIAP metrics as we diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index c73efa711..77b35f408 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -7,11 +7,6 @@ ======================== """ -import random -import copy -import numpy as np -from datetime import datetime, timedelta - # %% # Introduction # ------------ @@ -21,10 +16,14 @@ # We design two architectures: a centralised (non-hierarchical) architecture, and a hierarchical # alternative, and look to compare the fused results at the central node. # -# ## Scenario generation # # +import random +import copy +import numpy as np +from datetime import datetime, timedelta + start_time = datetime.now().replace(microsecond=0) np.random.seed(1990) random.seed(1990) @@ -33,7 +32,7 @@ # Sensors # ^^^^^^^ # -# We build two sensors to be assigned to the two sensor nodes +# We build two sensors to be assigned to the two sensor nodes. # from stonesoup.models.clutter import ClutterModel @@ -216,9 +215,9 @@ def is_clutter_detectable(self, state): # risk of data incest, due to the fact that information from sensor node 1 could reach fusion # node 1 via two routes, while appearing to not be from the same source: # -# Route 1: Sensor Node 1 (S1) passes its information straight to Fusion Node 1 (F1) +# - Route 1: Sensor Node 1 (S1) passes its information straight to Fusion Node 1 (F1) # -# Route 2: S1 also passes its information to Fusion Node 2 (F2). Here it is fused with +# - Route 2: S1 also passes its information to Fusion Node 2 (F2). Here it is fused with # information from Sensor Node 2 (S2). This resulting information is then passed to Fusion Node 1. # # Ultimately, F1 is recieving information from S1, and information from F2 which is based on the @@ -447,7 +446,7 @@ def reduce_tracks(tracks): from stonesoup.metricgenerator.metrictables import SIAPDiffTableGenerator -SIAPDiffTableGenerator([NH_siap_averages_EKF, H_siap_averages_EKF]).compute_metric() +SIAPDiffTableGenerator([NH_siap_averages_EKF, H_siap_averages_EKF]).compute_metric(); # %% diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 03ec6810c..50eb4578c 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -757,7 +757,8 @@ def test_net_arch_fully_propagated(generator_params, ground_truths): DataPiece( edge.sender, edge.sender, - Track([GaussianState([1, 2, 3, 4], np.diag([1, 1, 1, 1]), datetime.datetime(2016, 1, 2, 3, 4, 5))]), + Track([GaussianState([1, 2, 3, 4], np.diag([1, 1, 1, 1]), + datetime.datetime(2016, 1, 2, 3, 4, 5))]), datetime.datetime(2016, 1, 2, 3, 4, 5), ), ) From 6ac1984b0bbfc3c7dffc68d50916ef92f102470b Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 16 Sep 2024 19:49:25 +0100 Subject: [PATCH 151/170] flake8 --- stonesoup/architecture/tests/test_architecture.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stonesoup/architecture/tests/test_architecture.py b/stonesoup/architecture/tests/test_architecture.py index 50eb4578c..6f5d9ca65 100644 --- a/stonesoup/architecture/tests/test_architecture.py +++ b/stonesoup/architecture/tests/test_architecture.py @@ -757,7 +757,7 @@ def test_net_arch_fully_propagated(generator_params, ground_truths): DataPiece( edge.sender, edge.sender, - Track([GaussianState([1, 2, 3, 4], np.diag([1, 1, 1, 1]), + Track([GaussianState([1, 2, 3, 4], np.diag([1, 1, 1, 1]), datetime.datetime(2016, 1, 2, 3, 4, 5))]), datetime.datetime(2016, 1, 2, 3, 4, 5), ), From ae5f690adc5597a084c44c5ae1603c5cf205d32a Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 16 Sep 2024 20:55:49 +0100 Subject: [PATCH 152/170] bullet point fix --- .../architecture/02_Information_and_Network_Architectures.py | 5 ++--- docs/tutorials/architecture/03_Avoiding_Data_Incest.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index d02b1f902..e216f9d43 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -356,10 +356,9 @@ def reduce_tracks(tracks): # In this section, we take an identical copy of each of the architectures above, and remove an # edge. We aim to show the following: # -# - It is possible to remove certain edges from a network architecture without affecting the +# * It is possible to remove certain edges from a network architecture without affecting the # performance of the network. -# -# - Removing an edge from an information architecture will likely have an effect on performance. +# * Removing an edge from an information architecture will likely have an effect on performance. # # First, we must set up the two architectures, and remove an edge from each. In the network # architecture, there are multiple routes between some pairs of nodes. This redundency increases diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 77b35f408..55d5b1848 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -215,9 +215,8 @@ def is_clutter_detectable(self, state): # risk of data incest, due to the fact that information from sensor node 1 could reach fusion # node 1 via two routes, while appearing to not be from the same source: # -# - Route 1: Sensor Node 1 (S1) passes its information straight to Fusion Node 1 (F1) -# -# - Route 2: S1 also passes its information to Fusion Node 2 (F2). Here it is fused with +# * Route 1: Sensor Node 1 (S1) passes its information straight to Fusion Node 1 (F1) +# * Route 2: S1 also passes its information to Fusion Node 2 (F2). Here it is fused with # information from Sensor Node 2 (S2). This resulting information is then passed to Fusion Node 1. # # Ultimately, F1 is recieving information from S1, and information from F2 which is based on the From 0d33ffdf16cba4cf01438c2ba673b7fc372ac315 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 16 Sep 2024 22:48:00 +0100 Subject: [PATCH 153/170] bullet points... --- .../architecture/02_Information_and_Network_Architectures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index e216f9d43..c6b96d09a 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -359,7 +359,7 @@ def reduce_tracks(tracks): # * It is possible to remove certain edges from a network architecture without affecting the # performance of the network. # * Removing an edge from an information architecture will likely have an effect on performance. -# + # First, we must set up the two architectures, and remove an edge from each. In the network # architecture, there are multiple routes between some pairs of nodes. This redundency increases # the resilience of the network when an edge, or node, is taken out of action. In this example, From 4d55517de19286765fead78a821e8b059a355e20 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 16 Sep 2024 22:48:25 +0100 Subject: [PATCH 154/170] bullet points... --- docs/tutorials/architecture/03_Avoiding_Data_Incest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 55d5b1848..1d4fd0ac3 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -218,7 +218,7 @@ def is_clutter_detectable(self, state): # * Route 1: Sensor Node 1 (S1) passes its information straight to Fusion Node 1 (F1) # * Route 2: S1 also passes its information to Fusion Node 2 (F2). Here it is fused with # information from Sensor Node 2 (S2). This resulting information is then passed to Fusion Node 1. -# + # Ultimately, F1 is recieving information from S1, and information from F2 which is based on the # same information from S1. This can cause a bias towards the information created at S1. In this # example, we would expect to see overconfidence in the form of unrealistically small uncertainty From 96ab0288b10f4c0d52207805fc4772bd2e0f0f74 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 16 Sep 2024 23:35:32 +0100 Subject: [PATCH 155/170] bullet points... --- .../architecture/02_Information_and_Network_Architectures.py | 2 +- docs/tutorials/architecture/03_Avoiding_Data_Incest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index c6b96d09a..7a2b20dfb 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -357,7 +357,7 @@ def reduce_tracks(tracks): # edge. We aim to show the following: # # * It is possible to remove certain edges from a network architecture without affecting the -# performance of the network. +# performance of the network. # * Removing an edge from an information architecture will likely have an effect on performance. # First, we must set up the two architectures, and remove an edge from each. In the network diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 1d4fd0ac3..39d648299 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -217,7 +217,7 @@ def is_clutter_detectable(self, state): # # * Route 1: Sensor Node 1 (S1) passes its information straight to Fusion Node 1 (F1) # * Route 2: S1 also passes its information to Fusion Node 2 (F2). Here it is fused with -# information from Sensor Node 2 (S2). This resulting information is then passed to Fusion Node 1. +# information from Sensor Node 2 (S2). This resulting information is then passed to Fusion Node 1. # Ultimately, F1 is recieving information from S1, and information from F2 which is based on the # same information from S1. This can cause a bias towards the information created at S1. In this From 6ab5d3b8262ca17fbdca2405e2bb32b37a6a651e Mon Sep 17 00:00:00 2001 From: spike Date: Tue, 17 Sep 2024 12:44:55 +0100 Subject: [PATCH 156/170] Add description to architecture tutorial 3 --- .../01_Introduction_to_Architectures.py | 16 ++--- ...2_Information_and_Network_Architectures.py | 38 ++++++----- .../architecture/03_Avoiding_Data_Incest.py | 64 +++++++++++++------ 3 files changed, 72 insertions(+), 46 deletions(-) diff --git a/docs/tutorials/architecture/01_Introduction_to_Architectures.py b/docs/tutorials/architecture/01_Introduction_to_Architectures.py index aa28f0ad8..399b59134 100644 --- a/docs/tutorials/architecture/01_Introduction_to_Architectures.py +++ b/docs/tutorials/architecture/01_Introduction_to_Architectures.py @@ -19,8 +19,8 @@ # Nodes # ----- # -# Nodes represent points in the architecture that collect, process (fuse), or simply forward on data. Before advancing, -# a few definitions are required: +# Nodes represent points in the architecture that collect, process (fuse), or simply forward on +# data. Before advancing, a few definitions are required: # # - Relationships between nodes are defined as parent-child. In a directed graph, an edge from # node A to node B means that data is passed from the child node, A, to the parent node, B. @@ -55,9 +55,9 @@ node_C = Node(label='Node C') # %% -# The :class:`.~Node` base class contains several properties. The `latency` property gives functionality to -# simulate processing latency at the node. The rest of the properties (`label`, `position`, `colour`, -# `shape`, `font_size`, `node_dim`), are used for graph plotting. +# The :class:`.~Node` base class contains several properties. The `latency` property gives +# functionality to simulate processing latency at the node. The rest of the properties (`label`, +# `position`, `colour`, `shape`, `font_size`, `node_dim`), are used for graph plotting. node_A.colour = '#006494' @@ -67,9 +67,9 @@ # :class:`~.SensorNode` and :class:`~.FusionNode` objects have additional properties that must be # defined. A :class:`~.SensorNode` must be given an additional `sensor` property - this must be a # :class:`~.Sensor`. A :class:`~.FusionNode` has two additional properties: `tracker` and -# `fusion_queue`.`tracker` must both be :class:`~.Tracker`\s - the main tracker manages the fusion at -# the node, while the `fusion_queue` property is a :class:`~.FusionQueue` by default - this manages the -# inflow of data from child nodes. +# `fusion_queue`.`tracker` must both be :class:`~.Tracker`\s - the main tracker manages the +# fusion at the node, while the `fusion_queue` property is a :class:`~.FusionQueue` by default - +# this manages the inflow of data from child nodes. # # Edges # ----- diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 7a2b20dfb..19b90ef7a 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -19,8 +19,8 @@ # # 2) Build a base sensor model, and a base tracker # -# 3) Use the :class:`~.ArchitectureGenerator` classes to generate 2 pairs of identical architectures -# (one of each type), where the network architecture is a valid representation of +# 3) Use the :class:`~.ArchitectureGenerator` classes to generate 2 pairs of identical +# architectures (one of each type), where the network architecture is a valid representation of # the information architecture. # # 4) Run the simulation over both, and compare results. @@ -39,7 +39,7 @@ # %% # 1 - Ground Truth # ---------------- -# We start this tutorial by generating a set of :class:`~.GroundTruthPath`\s as a basis for a +# We start this tutorial by generating a set of :class:`~.GroundTruthPath`'s as a basis for a # tracking simulation. @@ -83,10 +83,11 @@ # %% # 2 - Base Tracker and Sensor Models # ---------------------------------- -# We can use the :class:`~.ArchitectureGenerator` classes to generate multiple identical architectures. -# These classes take in base tracker and sensor models, which are duplicated and applied to each -# relevant node in the architecture. The base tracker must not have a detector, in order for it -# to be duplicated - the detector will be applied during the architecture generation step. +# We can use the :class:`~.ArchitectureGenerator` classes to generate multiple identical +# architectures. These classes take in base tracker and sensor models, which are duplicated and +# applied to each relevant node in the architecture. The base tracker must not have a detector, +# in order for it to be duplicated - the detector will be applied during the architecture +# generation step. # # Sensor Model # ^^^^^^^^^^^^ @@ -162,8 +163,8 @@ # ------------------------------------ # The :class:`~.NetworkArchitecture` class has a property `information_arch`, which contains the # information architecture representation of the underlying network architecture. This means -# that if we use the :class:`~.NetworkArchitectureGenerator` class to generate a pair of identical network -# architectures, we can extract the information architecture from one. +# that if we use the :class:`~.NetworkArchitectureGenerator` class to generate a pair of identical +# network architectures, we can extract the information architecture from one. # # This will provide us with two completely separate architecture classes: a network architecture, # and an information architecture representation of the same network architecture. This will @@ -276,7 +277,9 @@ def reduce_tracks(tracks): for node in information_arch.fusion_nodes: if True: hexcol = ["#"+''.join([random.choice('ABCDEF0123456789') for i in range(6)])] - plotter.plot_tracks(reduce_tracks(node.tracks), [0, 2], track_label=str(node.label), line=dict(color=hexcol[0]), uncertainty=True) + plotter.plot_tracks(reduce_tracks(node.tracks), [0, 2], + track_label=str(node.label), + line=dict(color=hexcol[0]), uncertainty=True) plotter.plot_sensors(ia_sensors) plotter.plot_measurements(ia_dets, [0, 2]) plotter.fig @@ -318,8 +321,8 @@ def reduce_tracks(tracks): # %% network_siap_metrics = network_metrics['network_siap'] -network_siap_averages = {network_siap_metrics.get(metric) for metric in network_siap_metrics - if metric.startswith("SIAP") and not metric.endswith(" at times")} +network_siap_averages = {network_siap_metrics.get(metric) for metric in network_siap_metrics if + metric.startswith("SIAP") and not metric.endswith(" at times")} # %% top_node = information_arch.top_level_nodes.pop() @@ -348,7 +351,7 @@ def reduce_tracks(tracks): # %% from stonesoup.metricgenerator.metrictables import SIAPDiffTableGenerator -SIAPDiffTableGenerator([network_siap_averages, information_siap_averages]).compute_metric(); +SIAPDiffTableGenerator([network_siap_averages, information_siap_averages]).compute_metric() # %% # 5 - Remove edges from each architecture and re-run @@ -359,7 +362,7 @@ def reduce_tracks(tracks): # * It is possible to remove certain edges from a network architecture without affecting the # performance of the network. # * Removing an edge from an information architecture will likely have an effect on performance. - +# # First, we must set up the two architectures, and remove an edge from each. In the network # architecture, there are multiple routes between some pairs of nodes. This redundency increases # the resilience of the network when an edge, or node, is taken out of action. In this example, @@ -394,7 +397,8 @@ def reduce_tracks(tracks): rm = [] for edge in information_arch_rm.edges: - if 'sf0' in [node.label for node in edge.nodes] and 'f1' in [node.label for node in edge.nodes]: + if ('sf0' in [node.label for node in edge.nodes]) and \ + ('f1' in [node.label for node in edge.nodes]): rm.append(edge) for edge in rm: @@ -444,8 +448,8 @@ def reduce_tracks(tracks): truths_key='truths' ) -information_rm_metric_manager = MultiManager([information_rm_siap, - ], associator) # associator for generating SIAP metrics +information_rm_metric_manager = MultiManager([information_rm_siap], + associator) # associator for generating SIAP metrics information_rm_metric_manager.add_data({'information_rm_tracks': top_node.tracks, 'truths': truths}, overwrite=False) information_rm_metrics = information_rm_metric_manager.generate_metrics() diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 39d648299..7ef184c06 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -13,11 +13,26 @@ # This tutorial uses the Stone Soup Architecture module to provide an example of how data incest # can occur in a poorly designed network. # -# We design two architectures: a centralised (non-hierarchical) architecture, and a hierarchical +# In this example, data incest is represented by the scenario where a fusion node receives the +# same piece of data from two sources, without the knowledge that both pieces of information are +# actually from the same source. Having received two copies of the information, the fusion node +# becomes overconfident, or biased towards the duplicated data. +# +# This tutorial intends to demonstrate this effect by modelling two similar information +# architectures: a centralised (non-hierarchical) architecture, and a hierarchical # alternative, and look to compare the fused results at the central node. # +# This tutorial will follow the following steps: # +# 1) Build sensors for sensor nodes # +# 2) Build a ground truth, as a basis for the simulation +# +# 3) Build trackers for fusion nodes +# +# 4) Build a non hierarchical architecture +# +# 5) Build a hierarchical architecture by removing an edge from the non hierarchical architecture import random import copy @@ -29,8 +44,8 @@ random.seed(1990) # %% -# Sensors -# ^^^^^^^ +# 1) Sensors +# ^^^^^^^^^^ # # We build two sensors to be assigned to the two sensor nodes. # @@ -77,8 +92,9 @@ def is_clutter_detectable(self, state): sensor2.clutter_model.distribution = sensor2.clutter_model.random_state.uniform # %% -# Ground Truth -# ^^^^^^^^^^^^ +# 2) Ground Truth +# ^^^^^^^^^^^^^^^ +# Ground truth provides a basis for the tracking example from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, \ ConstantVelocity @@ -115,8 +131,8 @@ def is_clutter_detectable(self, state): ydirection *= -1 # %% -# Build Tracker -# ^^^^^^^^^^^^^ +# 3) Build Trackers +# ^^^^^^^^^^^^^^^^^ # We use the same configuration of trackers and track-trackers as we did in the previous tutorial. # @@ -167,15 +183,14 @@ def is_clutter_detectable(self, state): initiator, deleter, None, data_associator, detection_track_updater) # %% -# Non-Hierarchical Architecture -# ----------------------------- +# 4) Non-Hierarchical Architecture +# -------------------------------- # We start by constructing the non-hierarchical, centralised architecture. # # Nodes # ^^^^^ -# -from stonesoup.architecture.node import SensorNode, FusionNode, SensorFusionNode +from stonesoup.architecture.node import SensorNode, FusionNode sensornode1 = SensorNode(sensor=copy.deepcopy(sensor1), label='Sensor Node 1') sensornode1.sensor.clutter_model.distribution = \ @@ -217,8 +232,9 @@ def is_clutter_detectable(self, state): # # * Route 1: Sensor Node 1 (S1) passes its information straight to Fusion Node 1 (F1) # * Route 2: S1 also passes its information to Fusion Node 2 (F2). Here it is fused with -# information from Sensor Node 2 (S2). This resulting information is then passed to Fusion Node 1. - +# information from Sensor Node 2 (S2). This resulting information is then passed to Fusion +# Node 1. +# # Ultimately, F1 is recieving information from S1, and information from F2 which is based on the # same information from S1. This can cause a bias towards the information created at S1. In this # example, we would expect to see overconfidence in the form of unrealistically small uncertainty @@ -226,7 +242,7 @@ def is_clutter_detectable(self, state): NH_architecture = InformationArchitecture(NH_edges, current_time=start_time, use_arrival_time=True) -NH_architecture.plot(plot_style='hierarchical') # Style similar to hierarchical +# NH_architecture.plot(plot_style='hierarchical') # Style similar to hierarchical NH_architecture # %% @@ -276,18 +292,22 @@ def reduce_tracks(tracks): plotter.fig # %% -# Hierarchical Architecture -# ------------------------- +# 5) Hierarchical Architecture +# ---------------------------- # We now create an alternative architecture. We recreate the same set of nodes as before, but # with a new edge set, which is a subset of the edge set used in the non-hierarchical # architecture. +# +# In this architecture, by removing the edge joining sensor node 1 to fusion node 2, we prevent +# data incest by removing the second path which data from sensor node 1 can take to reach +# fusion node 1. # %% # Regenerate Nodes identical to those in the non-hierarchical example # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # -from stonesoup.architecture.node import SensorNode, FusionNode, SensorFusionNode +from stonesoup.architecture.node import SensorNode, FusionNode sensornode1B = SensorNode(sensor=copy.deepcopy(sensor1), label='Sensor Node 1') sensornode1B.sensor.clutter_model.distribution = \ @@ -402,7 +422,7 @@ def reduce_tracks(tracks): NH_siap_averages_EKF = {NH_siap_metrics.get(metric) for metric in NH_siap_metrics if metric.startswith("SIAP") and not metric.endswith(" at times")} -#%% +# %% # Calculate Metrics for Hierarchical Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # @@ -432,6 +452,7 @@ def reduce_tracks(tracks): # %% # Compare Metrics # --------------- +# %% # SIAP Metrics # ^^^^^^^^^^^^ # @@ -445,7 +466,7 @@ def reduce_tracks(tracks): from stonesoup.metricgenerator.metrictables import SIAPDiffTableGenerator -SIAPDiffTableGenerator([NH_siap_averages_EKF, H_siap_averages_EKF]).compute_metric(); +SIAPDiffTableGenerator([NH_siap_averages_EKF, H_siap_averages_EKF]).compute_metric() # %% @@ -469,7 +490,8 @@ def reduce_tracks(tracks): pcrb = PCRBMetric(prior=prior, transition_model=transition_model, measurement_model=LinearGaussian(ndim_state=4, mapping=[0, 2], - noise_covar=CovarianceMatrix(np.diag([5., 5.]))), + noise_covar=CovarianceMatrix( + np.diag([5., 5.]))), sensor_locations=StateVectors([sensor1.position, sensor2.position]), position_mapping=[0, 2], velocity_mapping=[1, 3], @@ -488,4 +510,4 @@ def reduce_tracks(tracks): # %% pcrb_metrics - + \ No newline at end of file From 9bec8baa8559379023c150d7c5ee04ead0e38c57 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Tue, 17 Sep 2024 21:17:37 +0100 Subject: [PATCH 157/170] minor grammar fixes --- .../architecture/02_Information_and_Network_Architectures.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 19b90ef7a..cc0af021b 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -39,7 +39,7 @@ # %% # 1 - Ground Truth # ---------------- -# We start this tutorial by generating a set of :class:`~.GroundTruthPath`'s as a basis for a +# We start this tutorial by generating a set of :class:`~.GroundTruthPath`s as a basis for a # tracking simulation. @@ -94,7 +94,7 @@ # The base sensor model's `position` property is used to calculate a location for sensors in # the architectures that we will generate. As you'll see in later steps, we can either plot # all sensors at the same location (`base_sensor.position`), or in a specified range around -# the base_sensor's position (`base_sensor.position` +- a specified distance). +# the base sensor's position (`base_sensor.position` +- a specified distance). from stonesoup.types.state import StateVector From 01e00d8739d25d2500c6800654e62a0858151a8c Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Tue, 17 Sep 2024 23:01:29 +0100 Subject: [PATCH 158/170] backslash needed --- .../architecture/02_Information_and_Network_Architectures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index cc0af021b..72bd6cfc3 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -39,7 +39,7 @@ # %% # 1 - Ground Truth # ---------------- -# We start this tutorial by generating a set of :class:`~.GroundTruthPath`s as a basis for a +# We start this tutorial by generating a set of :class:`~.GroundTruthPath`\s as a basis for a # tracking simulation. From f597e1660705476464a635b791460532a7e5426b Mon Sep 17 00:00:00 2001 From: spike Date: Fri, 20 Sep 2024 10:03:11 +0100 Subject: [PATCH 159/170] Arch Tutorial 3: Replace Siap metrics with covariance based metric. --- .../architecture/03_Avoiding_Data_Incest.py | 141 ++++-------------- 1 file changed, 31 insertions(+), 110 deletions(-) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 7ef184c06..8babd6df2 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -36,7 +36,9 @@ import random import copy +import math import numpy as np +import matplotlib.pyplot as plt from datetime import datetime, timedelta start_time = datetime.now().replace(microsecond=0) @@ -391,123 +393,42 @@ def reduce_tracks(tracks): # # %% -# Calculate SIAP metrics for Centralised Architecture -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Trace of Covariance Matrix +# ^^^^^^^^^^^^^^^^^^^^^^^^^^ # - -from stonesoup.metricgenerator.tracktotruthmetrics import SIAPMetrics -from stonesoup.measures import Euclidean -from stonesoup.dataassociator.tracktotrack import TrackToTruth -from stonesoup.metricgenerator.manager import MultiManager - -NH_siap_EKF_truth = SIAPMetrics(position_measure=Euclidean((0, 2)), - velocity_measure=Euclidean((1, 3)), - generator_name='NH_SIAP_EKF-truth', - tracks_key='NH_EKF_tracks', - truths_key='truths' - ) - -associator = TrackToTruth(association_threshold=30) - -# %% -NH_metric_manager = MultiManager([NH_siap_EKF_truth, - ], associator) # associator for generating SIAP metrics -NH_metric_manager.add_data({'NH_EKF_tracks': fusion_node1.tracks, - 'truths': truths, - 'NH_detections': NH_dets}, overwrite=False) -NH_metrics = NH_metric_manager.generate_metrics() - -# %% -NH_siap_metrics = NH_metrics['NH_SIAP_EKF-truth'] -NH_siap_averages_EKF = {NH_siap_metrics.get(metric) for metric in NH_siap_metrics - if metric.startswith("SIAP") and not metric.endswith(" at times")} - -# %% -# Calculate Metrics for Hierarchical Architecture -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# A consequence of data incest in tracking is overconfidence in track states. In this example we +# would expect to see unrealistically small uncertainty in the tracks generated by Fusion Node 1 +# in the non-hierarchical architecture. # - -H_siap_EKF_truth = SIAPMetrics(position_measure=Euclidean((0, 2)), - velocity_measure=Euclidean((1, 3)), - generator_name='H_SIAP_EKF-truth', - tracks_key='H_EKF_tracks', - truths_key='truths' - ) - -associator = TrackToTruth(association_threshold=30) - -# %% -H_metric_manager = MultiManager([H_siap_EKF_truth - ], associator) # associator for generating SIAP metrics -H_metric_manager.add_data({'H_EKF_tracks': fusion_node1B.tracks, - 'truths': truths, - 'H_detections': H_dets}, overwrite=False) -H_metrics = H_metric_manager.generate_metrics() - -# %% -H_siap_metrics = H_metrics['H_SIAP_EKF-truth'] -H_siap_averages_EKF = {H_siap_metrics.get(metric) for metric in H_siap_metrics - if metric.startswith("SIAP") and not metric.endswith(" at times")} - -# %% -# Compare Metrics -# --------------- -# %% -# SIAP Metrics -# ^^^^^^^^^^^^ +# To investigate this, we plot the mean trace of the covariance matrix of track states at each +# time-step - for both architectures. We should expect to see that the uncertainty of the +# non-hierarchical architecture is lower than the hierarchical architecture - despite both +# receiving an identical set of measurements. # -# Below we plot a table of SIAP metrics for both architectures. This results in this comparison -# table show that the results from the hierarchical architecture outperform the results from the -# centralised architecture. -# -# Further below is plot of SIAP position accuracy over time for the duration of the simulation. -# Smaller values represent higher accuracy - -from stonesoup.metricgenerator.metrictables import SIAPDiffTableGenerator +NH_tracks = [node.tracks for node in + NH_architecture.fusion_nodes if node.label == 'Fusion Node 1'][0] +H_tracks = [node.tracks for node in + H_architecture.fusion_nodes if node.label == 'Fusion Node 1'][0] -SIAPDiffTableGenerator([NH_siap_averages_EKF, H_siap_averages_EKF]).compute_metric() +NH_mean_covar_trace = [] +H_mean_covar_trace = [] -# %% - -from stonesoup.plotter import MetricPlotter +for t in timesteps: + NH_states = sum([[state for state in track.states if state.timestamp == t] for track in + NH_tracks], []) + H_states = sum([[state for state in track.states if state.timestamp == t] for track in + H_tracks], []) -combined_metrics = H_metrics | NH_metrics -graph = MetricPlotter() -graph.plot_metrics(combined_metrics, generator_names=['H_SIAP_EKF-truth', - 'NH_SIAP_EKF-truth'], - metric_names=['SIAP Position Accuracy at times'], - color=['red', 'blue']) + NH_trace_mean = np.mean([np.trace(s.covar) for s in NH_states]) + H_trace_mean = np.mean([np.trace(s.covar) for s in H_states]) + + NH_mean_covar_trace.append(NH_trace_mean if not math.isnan(NH_trace_mean) else 0) + H_mean_covar_trace.append(H_trace_mean if not math.isnan(H_trace_mean) else 0) # %% -# PCRB Metric -# ^^^^^^^^^^^ - -from stonesoup.models.measurement.linear import LinearGaussian -from stonesoup.types.array import CovarianceMatrix, StateVectors -from stonesoup.metricgenerator.pcrbmetric import PCRBMetric - -pcrb = PCRBMetric(prior=prior, - transition_model=transition_model, - measurement_model=LinearGaussian(ndim_state=4, mapping=[0, 2], - noise_covar=CovarianceMatrix( - np.diag([5., 5.]))), - sensor_locations=StateVectors([sensor1.position, sensor2.position]), - position_mapping=[0, 2], - velocity_mapping=[1, 3], - generator_name='PCRB Metrics' - ) -# %% -manager = MultiManager([pcrb]) - -manager.add_data({'groundtruth_paths': truths}) - -metrics = manager.generate_metrics() - -# %% -pcrb_metrics = metrics['PCRB Metrics'] - -# %% -pcrb_metrics - \ No newline at end of file +plt.plot(NH_mean_covar_trace, label="Non Hierarhical") +plt.plot(H_mean_covar_trace, label="Hierarchical") +plt.legend(loc="upper left") +plt.show() From 2888d309d81a0fe3e135f8cd9be3400285c89bb2 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 23 Sep 2024 00:49:45 +0100 Subject: [PATCH 160/170] refine architecture tutorial 3 --- .../architecture/03_Avoiding_Data_Incest.py | 128 ++++++++++-------- 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 8babd6df2..4e8ce5161 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -10,29 +10,35 @@ # %% # Introduction # ------------ -# This tutorial uses the Stone Soup Architecture module to provide an example of how data incest -# can occur in a poorly designed network. +# This tutorial uses the Stone Soup architecture module to provide an example +# of how data incest can occur in a poorly designed network. # -# In this example, data incest is represented by the scenario where a fusion node receives the -# same piece of data from two sources, without the knowledge that both pieces of information are -# actually from the same source. Having received two copies of the information, the fusion node -# becomes overconfident, or biased towards the duplicated data. +# In this example, data incest is shown in a simple architecture. A top-level +# fusion node receives data from two sources, which contain information (tracks) +# sourced from two sensors. However, one sensor is overly represented, due to a +# triangle in the information architecture graph. As a consequence, the fusion +# node becomes overconfident, or biased towards the duplicated data. # -# This tutorial intends to demonstrate this effect by modelling two similar information -# architectures: a centralised (non-hierarchical) architecture, and a hierarchical -# alternative, and look to compare the fused results at the central node. +# The aim is to demonstrate this effect by modelling two similar +# information architectures: a centralised (non-hierarchical) architecture, +# and a hierarchical alternative, and looks to compare the fused results +# at the top-level node. # -# This tutorial will follow the following steps: +# We will follow the following steps: # -# 1) Build sensors for sensor nodes +# 1) Define sensors for sensor nodes # -# 2) Build a ground truth, as a basis for the simulation +# 2) Simulate a ground truth, as a basis for the simulation # -# 3) Build trackers for fusion nodes +# 3) Create trackers for fusion nodes # -# 4) Build a non hierarchical architecture +# 4) Build a non-hierarchical architecture, containing a triangle # -# 5) Build a hierarchical architecture by removing an edge from the non hierarchical architecture +# 5) Build a hierarchical architecture by removing an edge +# from the non-hierarchical architecture +# +# 6) Compare and contrast. What difference, if any, will the +# hierarchical alternative make? import random import copy @@ -49,8 +55,8 @@ # 1) Sensors # ^^^^^^^^^^ # -# We build two sensors to be assigned to the two sensor nodes. -# +# We need two sensors to be assigned to the two sensor nodes. +# Notice they vary only in their position. from stonesoup.models.clutter import ClutterModel from stonesoup.models.measurement.linear import LinearGaussian @@ -96,7 +102,6 @@ def is_clutter_detectable(self, state): # %% # 2) Ground Truth # ^^^^^^^^^^^^^^^ -# Ground truth provides a basis for the tracking example from stonesoup.models.transition.linear import CombinedLinearGaussianTransitionModel, \ ConstantVelocity @@ -135,7 +140,8 @@ def is_clutter_detectable(self, state): # %% # 3) Build Trackers # ^^^^^^^^^^^^^^^^^ -# We use the same configuration of trackers and track-trackers as we did in the previous tutorial. +# We use the same configuration of trackers and track-trackers as we did in the +# previous tutorial. # from stonesoup.predictor.kalman import KalmanPredictor @@ -228,23 +234,26 @@ def is_clutter_detectable(self, state): # %% # Create the Non-Hierarchical Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# The cell below should create and plot the architecture we have built. This architecture is at -# risk of data incest, due to the fact that information from sensor node 1 could reach fusion -# node 1 via two routes, while appearing to not be from the same source: +# The cell below should create and plot the architecture we have built. +# This architecture is at risk of data incest, due to the fact that +# information from sensor node 1 could reach Fusion Node 1 via two routes, +# while appearing to not be from the same source: # -# * Route 1: Sensor Node 1 (S1) passes its information straight to Fusion Node 1 (F1) -# * Route 2: S1 also passes its information to Fusion Node 2 (F2). Here it is fused with -# information from Sensor Node 2 (S2). This resulting information is then passed to Fusion -# Node 1. +# * Route 1: Sensor Node 1 (S1) passes its information straight to +# Fusion Node 1 (F1) +# * Route 2: S1 also passes its information to Fusion Node 2 (F2). +# Here it is fused with information from Sensor Node 2 (S2). This +# resulting information is then passed to Fusion Node 1. # -# Ultimately, F1 is recieving information from S1, and information from F2 which is based on the -# same information from S1. This can cause a bias towards the information created at S1. In this -# example, we would expect to see overconfidence in the form of unrealistically small uncertainty -# of the output tracks. +# Ultimately, F1 is recieving information from S1, and information from +# F2 which is based on the same information from S1. This can cause a +# bias towards the information created at S1. In this example, we would +# expect to see overconfidence in the form of unrealistically small +# uncertainty of the output tracks. -NH_architecture = InformationArchitecture(NH_edges, current_time=start_time, use_arrival_time=True) -# NH_architecture.plot(plot_style='hierarchical') # Style similar to hierarchical +NH_architecture = InformationArchitecture(NH_edges, current_time=start_time, + use_arrival_time=True) NH_architecture # %% @@ -257,7 +266,7 @@ def is_clutter_detectable(self, state): NH_architecture.propagate(time_increment=1) # %% -# Extract all detections that arrived at Non-Hierarchical Node C +# Extract all Detections that arrived at Non-Hierarchical Node C # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # @@ -296,16 +305,16 @@ def reduce_tracks(tracks): # %% # 5) Hierarchical Architecture # ---------------------------- -# We now create an alternative architecture. We recreate the same set of nodes as before, but -# with a new edge set, which is a subset of the edge set used in the non-hierarchical -# architecture. +# We now create an alternative architecture. We recreate the same set of +# nodes as before, but with a new edge set, which is a subset of the edge +# set used in the non-hierarchical architecture. # -# In this architecture, by removing the edge joining sensor node 1 to fusion node 2, we prevent -# data incest by removing the second path which data from sensor node 1 can take to reach -# fusion node 1. +# In this architecture, by removing the edge joining sensor node 1 to +# fusion node 2, we prevent data incest by removing the second path which +# data from sensor node 1 can take to reach fusion node 1. # %% -# Regenerate Nodes identical to those in the non-hierarchical example +# Regenerate nodes identical to those in the non-hierarchical example # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # @@ -341,10 +350,13 @@ def reduce_tracks(tracks): # %% # Create the Hierarchical Architecture # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# The only difference between the two architectures is the removal of the edge from S1 to F2. -# This change removes the second route for information to travel from S1 to F1. +# The only difference between the two architectures is the removal +# of the edge from Sensor Node 1 to Fusion Node 2. This change removes +# the second route for information to travel from Sensor Node 1 to +# Fusion Node 1. -H_architecture = InformationArchitecture(H_edges, current_time=start_time, use_arrival_time=True) +H_architecture = InformationArchitecture(H_edges, current_time=start_time, + use_arrival_time=True) H_architecture # %% @@ -387,23 +399,23 @@ def reduce_tracks(tracks): # %% # Metrics # ------- -# At a glance, the results from the hierarchical architecture look similar to the results from -# the original centralised architecture. We will now calculate and plot some metrics to give an -# insight into the differences. -# +# At a glance, the results from the hierarchical architecture look similar +# to the results from the original centralised architecture. We will now +# calculate and plot some metrics to give an insight into the differences. # %% # Trace of Covariance Matrix # ^^^^^^^^^^^^^^^^^^^^^^^^^^ # -# A consequence of data incest in tracking is overconfidence in track states. In this example we -# would expect to see unrealistically small uncertainty in the tracks generated by Fusion Node 1 -# in the non-hierarchical architecture. +# A consequence of data incest in tracking is overconfidence in track states. +# In this example we would expect to see unrealistically small uncertainty in +# the tracks generated by Fusion Node 1 in the non-hierarchical architecture. # -# To investigate this, we plot the mean trace of the covariance matrix of track states at each -# time-step - for both architectures. We should expect to see that the uncertainty of the -# non-hierarchical architecture is lower than the hierarchical architecture - despite both -# receiving an identical set of measurements. +# To investigate this, we plot the mean trace of the covariance matrix of track +# states at each time step -- for both architectures. We should expect to see +# that the uncertainty of the non-hierarchical architecture is lower than the +# hierarchical architecture, despite both receiving an identical set of +# measurements. # NH_tracks = [node.tracks for node in @@ -428,7 +440,11 @@ def reduce_tracks(tracks): # %% -plt.plot(NH_mean_covar_trace, label="Non Hierarhical") +plt.plot(NH_mean_covar_trace, label="Non-Hierarchical") plt.plot(H_mean_covar_trace, label="Hierarchical") -plt.legend(loc="upper left") +plt.legend(loc="upper right") plt.show() + +# As expected, the plot shows that the non-hierarchical architecture has a +# lower mean covariance trace. A naive observer may think this makes it higher +# performing, but we know that in fact it is a sign of overconfidence. \ No newline at end of file From 7077bee7ba66dad921038255230697396923989d Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 23 Sep 2024 10:19:06 +0100 Subject: [PATCH 161/170] fix bullet points againnn --- docs/tutorials/architecture/03_Avoiding_Data_Incest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 4e8ce5161..29050b8ad 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -39,6 +39,7 @@ # # 6) Compare and contrast. What difference, if any, will the # hierarchical alternative make? +# import random import copy From 62dd910a14a822bc704930901a37ef4930440314 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 23 Sep 2024 11:04:58 +0100 Subject: [PATCH 162/170] fix bullet points againnnnn --- docs/tutorials/architecture/03_Avoiding_Data_Incest.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 29050b8ad..b9ae51f56 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -39,7 +39,6 @@ # # 6) Compare and contrast. What difference, if any, will the # hierarchical alternative make? -# import random import copy @@ -241,10 +240,10 @@ def is_clutter_detectable(self, state): # while appearing to not be from the same source: # # * Route 1: Sensor Node 1 (S1) passes its information straight to -# Fusion Node 1 (F1) +# Fusion Node 1 (F1) # * Route 2: S1 also passes its information to Fusion Node 2 (F2). -# Here it is fused with information from Sensor Node 2 (S2). This -# resulting information is then passed to Fusion Node 1. +# Here it is fused with information from Sensor Node 2 (S2). This +# resulting information is then passed to Fusion Node 1. # # Ultimately, F1 is recieving information from S1, and information from # F2 which is based on the same information from S1. This can cause a From f9fabc19625c789d8bc4900b3cd4954d5878cea4 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 23 Sep 2024 11:48:44 +0100 Subject: [PATCH 163/170] fix list --- docs/tutorials/architecture/03_Avoiding_Data_Incest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index b9ae51f56..4c0d0e301 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -35,10 +35,10 @@ # 4) Build a non-hierarchical architecture, containing a triangle # # 5) Build a hierarchical architecture by removing an edge -# from the non-hierarchical architecture +# from the non-hierarchical architecture # # 6) Compare and contrast. What difference, if any, will the -# hierarchical alternative make? +# hierarchical alternative make? import random import copy From 3e651ae80701123898fd57502de43c88ad4d06c8 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 23 Sep 2024 12:34:57 +0100 Subject: [PATCH 164/170] fix list --- docs/tutorials/architecture/03_Avoiding_Data_Incest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py index 4c0d0e301..35ce0e44a 100644 --- a/docs/tutorials/architecture/03_Avoiding_Data_Incest.py +++ b/docs/tutorials/architecture/03_Avoiding_Data_Incest.py @@ -35,10 +35,11 @@ # 4) Build a non-hierarchical architecture, containing a triangle # # 5) Build a hierarchical architecture by removing an edge -# from the non-hierarchical architecture +# from the non-hierarchical architecture # # 6) Compare and contrast. What difference, if any, will the -# hierarchical alternative make? +# hierarchical alternative make? +# import random import copy From f142cd18c3ec102198df35fb5c5273ac329e56d4 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Tue, 24 Sep 2024 16:04:33 +0100 Subject: [PATCH 165/170] bug fix --- docs/tutorials/architecture/README.rst | 2 +- stonesoup/architecture/edge.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/architecture/README.rst b/docs/tutorials/architecture/README.rst index 960b0f2ff..c4e243f5a 100644 --- a/docs/tutorials/architecture/README.rst +++ b/docs/tutorials/architecture/README.rst @@ -1,3 +1,3 @@ Architecture ------------ -Here are some tutorials which cover use of the stonesoup.architecture package. +Here are some tutorials which cover use of fusion architectures in Stone Soup. diff --git a/stonesoup/architecture/edge.py b/stonesoup/architecture/edge.py index 68ee80dec..76916ad00 100644 --- a/stonesoup/architecture/edge.py +++ b/stonesoup/architecture/edge.py @@ -197,7 +197,8 @@ def unpassed_data(self): @property def unsent_data(self): - """Data held by the sender that has not been sent to the recipient.""" + """Data modified by the sender that has not been sent to the + recipient.""" unsent = [] if isinstance(type(self.sender.data_held), type(None)) or self.sender.data_held is None: return unsent From 56ba8700d017dad39197e5569725ad060c9cde41 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Wed, 25 Sep 2024 15:57:17 +0100 Subject: [PATCH 166/170] match propogates architecture --- stonesoup/architecture/__init__.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 8518fd4af..6aed3d3be 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -400,11 +400,13 @@ def __len__(self): @property def fully_propagated(self): - """Checks if all data for each node have been transferred + """Checks if all data for each node have begun transfer to its recipients. With zero latency, this should be the case after running propagate""" for edge in self.edges.edges: if len(edge.unsent_data) != 0: return False + elif len(edge.unpassed_data) != 0: + return False return True @@ -473,6 +475,10 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): # Need to re-run update messages so that messages aren't left as 'pending' edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) + # Need to re-run update messages so that messages aren't left as 'pending' + edge.update_messages(self.current_time, + use_arrival_time=self.use_arrival_time) + for fuse_node in self.fusion_nodes: fuse_node.fuse() @@ -583,21 +589,6 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): else: self.propagate(time_increment, failed_edges) - @property - def fully_propagated(self): - """Checks if all data for each node have been transferred - to its recipients. With zero latency, this should be the case after running propagate""" - for edge in self.edges.edges: - if edge.sender in self.information_arch.all_nodes: - if len(edge.unsent_data) != 0: - return False - elif len(edge.unpassed_data) != 0: - return False - elif len(edge.unpassed_data) != 0: - return False - - return True - def inherit_edges(network_architecture): """ From 494de0b111d44ed70c363a920d5aa99fd50339dd Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Wed, 25 Sep 2024 16:31:22 +0100 Subject: [PATCH 167/170] flake8 --- stonesoup/architecture/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stonesoup/architecture/__init__.py b/stonesoup/architecture/__init__.py index 6aed3d3be..0d1abe4fa 100644 --- a/stonesoup/architecture/__init__.py +++ b/stonesoup/architecture/__init__.py @@ -400,7 +400,7 @@ def __len__(self): @property def fully_propagated(self): - """Checks if all data for each node have begun transfer + """Checks if all data for each node have begun transfer to its recipients. With zero latency, this should be the case after running propagate""" for edge in self.edges.edges: if len(edge.unsent_data) != 0: @@ -476,7 +476,7 @@ def propagate(self, time_increment: float, failed_edges: Collection = None): edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) # Need to re-run update messages so that messages aren't left as 'pending' - edge.update_messages(self.current_time, + edge.update_messages(self.current_time, use_arrival_time=self.use_arrival_time) for fuse_node in self.fusion_nodes: From 7c661aad3c77b563e0d420c88dff8d07b9baa30c Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Wed, 25 Sep 2024 22:52:23 +0100 Subject: [PATCH 168/170] the --- docs/tutorials/architecture/README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/architecture/README.rst b/docs/tutorials/architecture/README.rst index c4e243f5a..05495f2e4 100644 --- a/docs/tutorials/architecture/README.rst +++ b/docs/tutorials/architecture/README.rst @@ -1,3 +1,3 @@ Architecture ------------ -Here are some tutorials which cover use of fusion architectures in Stone Soup. +Here are some tutorials which cover the use of fusion architectures in Stone Soup. From 74808f96f7a37288c84831efbd31db2f575f1681 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Fri, 27 Sep 2024 18:59:52 +0100 Subject: [PATCH 169/170] fix list formatting --- .../02_Information_and_Network_Architectures.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index 72bd6cfc3..b6e0a661a 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -19,9 +19,9 @@ # # 2) Build a base sensor model, and a base tracker # -# 3) Use the :class:`~.ArchitectureGenerator` classes to generate 2 pairs of identical -# architectures (one of each type), where the network architecture is a valid representation of -# the information architecture. +# 3) Use the :class:`~.ArchitectureGenerator` classes to generate 2 pairs of +# identical architectures (one of each type), where the network architecture +# is a valid representation of the information architecture. # # 4) Run the simulation over both, and compare results. # @@ -468,4 +468,4 @@ def reduce_tracks(tracks): information_siap_averages, network_rm_siap_averages, information_rm_siap_averages], - ['Network', 'Info', 'Network RM', 'Info RM']).compute_metric() + ['Network', 'Info', 'Network RM', 'Info RM']).compute_metric(); From 49dd9259b8468fcac675b8b5e270ed7408c57173 Mon Sep 17 00:00:00 2001 From: Ollie Rosoman Date: Mon, 30 Sep 2024 14:58:43 +0100 Subject: [PATCH 170/170] formatting --- .../architecture/02_Information_and_Network_Architectures.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py index b6e0a661a..231fc946a 100644 --- a/docs/tutorials/architecture/02_Information_and_Network_Architectures.py +++ b/docs/tutorials/architecture/02_Information_and_Network_Architectures.py @@ -20,8 +20,8 @@ # 2) Build a base sensor model, and a base tracker # # 3) Use the :class:`~.ArchitectureGenerator` classes to generate 2 pairs of -# identical architectures (one of each type), where the network architecture -# is a valid representation of the information architecture. +# identical architectures (one of each type), where the network architecture +# is a valid representation of the information architecture. # # 4) Run the simulation over both, and compare results. #