from maybrain import brain as mbt

"""
Explanation: Simple Usage
Basic Principles
The purpose of Maybrain is to allow easy visualisation of brain connectome and related data and perform various analyses.
The code is built around the class Brain. It contains all the information about the brain and numerous functions to change, measure and highlight those. At its heart is a Networkx object, Brain.G, via which all Networkx functions are available.
Besides this main class, you have four packages with other modules, algorithms, plotting, utils and resources, which will be explained throughout these notebooks.
Data Input
Several types of data can be input. The basic connectome is made up of two files: a coordinate file and an adjacency matrix. In fact only the second of these is strictly required.
The coordinate file defines the position of each node. It is a text file where each line has four entries: the node index, x, y and z coordinates. e.g.:
0 0.0 3.1 4.4
1 5.3 7.6 8.4
2 3.2 4.4 3.1
The adjacency matrix defines the strength of connection between each pair of nodes. For n nodes it is an n × n text matrix. Nodes in maybrain are labelled 0,1,... and the order of the rows and columns in the adjacency matrix is assumed to correspond to this. Entering an adjacency matrix with the wrong dimensions will lead to certain doom.
Importing Maybrain
The main class of maybrain is contained in the brain module and can easily be achieved with a normal import.
End of explanation
"""


from maybrain import constants as ct
# Printing some of the constants
print(ct.WEIGHT)
print(ct.ANAT_LABEL)

"""
Explanation: The constants module
Before going further in explaining Maybrain's functionalities, it is important to briefly refer the constants module. This module has some constants which can be used elsewhere, rather than writing the values by hand everywhere being prone to typos. 
In further notebooks you will see this module being used in practice, but for now, also just a normal import is required:
End of explanation
"""


from maybrain import resources as rr

"""
Explanation: The resources package
Maybrain also have another package that can be useful for different things. In its essence, it is just a package with access to files like matrices, properties, etc. When importing this package, you will have access to different variables in the path for the file in your system.
Farther in the documentation you will see this package being used in practice, but for now, also just a normal import is required:
End of explanation
"""


a = mbt.Brain()
print("Nodes: ", a.G.nodes())
print("Edges: ", a.G.edges())
print("Adjacency matrix: ", a.adjMat)

"""
Explanation: Importing an Adjacency Matrix
Firstly, create a Brain object:
End of explanation
"""


a.import_adj_file(rr.DUMMY_ADJ_FILE_500)

print("Number of nodes:\n", a.G.number_of_nodes())
print("First 5 nodes (notice labelling starting with 0):\n", list(a.G.nodes())[0:5])
print("Edges:\n", a.G.edges())
print("Size of Adjacency matrix:\n", a.adjMat.shape)

"""
Explanation: This creates a brain object, where a graph (from the package NetworkX) is stored as a.G, initially empty. 
Then import the adjacency matrix. The import_adj_file() function imports the adjacency matrix to form the nodes of your graph, but does not create any edges (connections), as you can check from the following outputs.
Note the use of the resources package. In maybrain you can access a dummy adjacency matrix (500x500) for various reasons; in this case, just for testing purposes.
End of explanation
"""


# Bring everything from the adjacency matrix to a.G
a.apply_threshold()
print("Number of edges (notice it corresponds to the upper half edges of adjacency matrix):\n", a.G.number_of_edges())
print("Size of Adjacency matrix after 1st threshold:\n", a.adjMat.shape)

# Retain the most strongly connected 1000 edges
a.apply_threshold(threshold_type= "totalEdges", value = 1000) 
print("\nNumber of edges after 2nd threshold:\n", a.G.number_of_edges())
print("Size of Adjacency matrix after 2nd threshold:\n", a.adjMat.shape)

# Retain the 5% most connected edges as a percentage of the total possible number of edges
a.apply_threshold(threshold_type = "edgePC", value = 5) 
print("\nNumber of edges after 3rd threshold:\n", a.G.number_of_edges())
print("Size of Adjacency matrix after 3rd threshold:\n", a.adjMat.shape)

# Retain edges with a weight greater than 0.3
a.apply_threshold(threshold_type= "tVal", value = 0.3) 
print("\nNumber of edges after 4th threshold:\n", a.G.number_of_edges())
print("Size of Adjacency matrix after 4th threshold:\n", a.adjMat.shape)


"""
Explanation: If you wish to create a fully connected graph with all the available values in the adjacency matrix, it is necessary to threshold it, which is explained in the next section.
Thresholding
There are a few ways to apply a threshold, either using an absolute threshold across the whole graph to preserve a specified number of edges or percentage of total possible edges; or to apply a local thresholding that begins with the minimum spanning tree and adds successive n-nearest neighbour graphs. The advantage of local thresholding is that the graph will always be fully connected, which is necessary to collect some graph measures.
For an absolute threshold you have several possibilities. Note that our adjacency matrix (a.adjMat) always stays the same so we can apply all the thresholds we want to create our graph (a.G) accordingly. Also notice that in this specific case of an undirected graph, are dealing with a symmetric adjacency matrix, so although a.adjMat will always have the size of 500x500, the a.G will not.
End of explanation
"""


a.local_thresholding()
print("Is the graph connected? ", mbt.nx.is_connected(a.G))

a.local_thresholding(threshold_type="edgePC", value = 5)
print("Is the graph connected? ", mbt.nx.is_connected(a.G))

a.local_thresholding(threshold_type="totalEdges", value = 10000)
print("Is the graph connected? ", mbt.nx.is_connected(a.G))

"""
Explanation: The options for local thresholding are similar. Note that a local thresholding always yield a connected graph, and in the case where no arguments are passed, the graph will be the Minimum Spanning Tree. Local thresholding can be very slow for bigger matrices because in each step it is adding successive N-nearest neighbour degree graphs.
End of explanation
"""


# Thresholding the 80% most strongly connected edges
a.apply_threshold(threshold_type="edgePC", value=80)
for e in a.G.edges(data=True):
    # Printing the edges with negative weight
    if e[2][ct.WEIGHT] < 0:
        print(e) # This line is never executed because a negative weighted edge is not strong enough

# Absolute thresholding of the 70% most strongly connected edges 
print("Edges with negative weight which belong to the 70% strongest ones:")
a.apply_threshold(threshold_type="edgePC", value=70, use_absolute=True)
for e in a.G.edges(data=True):
    if e[2][ct.WEIGHT] < 0:
        print(e)
        
# Absolute thresholding of the 80% most strongly connected edges 
print("\nEdges with negative weight which belong to the 80% strongest ones:")
a.apply_threshold(threshold_type="edgePC", value=80, use_absolute=True)
for e in a.G.edges(data=True):
    if e[2][ct.WEIGHT] < 0:
        print(e)

"""
Explanation: Absolute Thresholding
In a real brain network, an edge with high negative value is as strong as an edge with a high positive value. So, if you want to threshold in order to get the most strongly connected edges (both negative and positive), you just have to pass an argument use_absolute=True to apply_threshold().
In the case of the brain that we are using in this notebook there are not many negative edges. Thus, we have to threshold the 80% most strongly connected edges in order to see a difference (notice the use of the module constants (ct) to access the weight property of each edge):
End of explanation
"""


a.binarise()
print("Do all the edges have weight of 1?", all(e[2][ct.WEIGHT] == 1 for e in a.G.edges(data=True)))

"""
Explanation: Binary and Absolute Graphs
If necessary the graph can be binarised so that weights are removed. You can see that essentially this means that each edge will have a weight of 1.
End of explanation
"""


# Applying threshold again because of last changes
a.apply_threshold()
print("Do all the edges have a positive weight before?", all(e[2][ct.WEIGHT] >= 0 for e in a.G.edges(data=True)))
a.make_edges_absolute()
print("Do all the edges have a positive weight?", all(e[2][ct.WEIGHT] >= 0 for e in a.G.edges(data=True)))

"""
Explanation: Also, you can make all the weights to have an absolute value, instead of negative and positive values:
End of explanation
"""


# Initially, you don't have anatomical/spatial attributes in each node:
print("Attributes: ", mbt.nx.get_node_attributes(a.G, ct.ANAT_LABEL), "/", mbt.nx.get_node_attributes(a.G, ct.XYZ))

#After calling import_spatial_info(), you can see the node's attributes
a.import_spatial_info(rr.MNI_SPACE_COORDINATES_500)
print("Attributes of one node: ", 
      mbt.nx.get_node_attributes(a.G, ct.ANAT_LABEL)[0], 
      "/", 
      mbt.nx.get_node_attributes(a.G, ct.XYZ)[0])

"""
Explanation: Importing 3D Spatial Information
You can add spatial info to each node of your graph. You need this information if you want to use the visualisation tools of Maybrain.
To do so, provide Maybrain with a file that has 4 columns: an anatomical label, and x, y and z coordinates. e.g.:
0 70.800000 30.600000 53.320000
1 32.064909 62.154158 69.707911
2 59.870968 92.230014 41.552595
3 19.703504 66.398922 52.878706
Ideally these values would be in MNI space (this makes it easier to import background images for plotting and for some other functions), but this is not absolutely necessary.
We are using the resources package again to get an already prepated text file with spatial information for a brain with 500 regions in the MNI template:
End of explanation
"""


# Creating a new Brain and importing the shorter adjacency matrix
b = mbt.Brain()
b.import_adj_file("data/3d_grid_adj.txt")
b.apply_threshold()

print("Edges and nodes information:")
for e in b.G.edges(data=True):
    print(e)
for n in b.G.nodes(data=True):
    print(n)

# Importing properties and showing again edges and nodes
print("\nImporting properties...")
b.import_properties("data/3d_grid_properties.txt")

print("\nEdges and nodes information after importing properties:")
for e in b.G.edges(data=True):
    print(e)
for n in b.G.nodes(data=True):
    print(n)


"""
Explanation: Properties in Nodes and Edges
We have seen already that nodes can have properties about spatial information after calling import_spatial_info(), and edges can have properties about weight after calling applying thresholds. 
You can add properties
to nodes or edges from a text file. The format should be as follows:
property
node1 value
node2 value2
(...)
node1 node2 value1
node3 node4 value2
(...)
Let's give a specific example. Imagine that you want to add properties about colours. You can use this file, which is transcribed here:
colour
1 red
3 red
6 green
0 blue
1 3 green
1 2 green
1 0 grey
2 3 green
2 0 red
3 0 green
Note that the first line contains the property name. Subsequent lines refer to edges if they contain 3 terms and nodes if they contain 2. The above will give node 1 the property 'colour' with value 'red' and node 6 the property 'colour' with value 'green'. Nodes 0 and 3 will also have the property 'colour' but with value 'blue' and 'red', respectively.
The edge connecting nodes 1 and 3 will have the same property with value 'green'. All the other 5 edges will have the same property but with different values. These properties are stored in the G object from networkx.
In order to be easier to see the properties features, we will be importing a shorter matrix with just 4 nodes (link here).
From the following code you can see that a warning is printed because we tried to add a property to a node 6, which doesn't exist. However, the other properties are added. 
Note the fact that as the brain is not directed, adding the property to the edge (1,0) is considered as adding to the edge (0,1). The same thing happens with edges (2,0) and (3,0). No property was imported to node 2 because it is not specified in the properties file.
End of explanation
"""


# Rethresholding the brain, thus loosing information
b.apply_threshold(threshold_type="totalEdges", value=0)
b.apply_threshold()

print("Edges information:")
for e in b.G.edges(data=True):
    print(e)

# Setting field to allow automatic importing of properties after a threshold
print("\nSetting b.update_properties_after_threshold and rethresholding again...")
b.apply_threshold(threshold_type="totalEdges", value=0)
b.update_props_after_threshold = True
b.apply_threshold() # Now, warning is thrown just like before

print("\nEdges information again:")
for e in b.G.edges(data=True):
    print(e)

"""
Explanation: You can notice that if we threshold our brain again, edges are created from scratch and thus properties are lost. The same doesn't happen with nodes as they are always present in our G object.
By default, properties of the edges are not imported everytime you threshold the brain. However, you can change that behaviour by setting the field update_props_after_threshold to True.
End of explanation
"""


nodes_props = {0: "val1", 1: "val2"}
edges_props = {(0, 1): "edge_val1", (2,3): "edge_val2"}

b.import_edge_props_from_dict("own_property", edges_props)
b.import_node_props_from_dict("own_property", nodes_props)

print("\nEdges information:")
for e in b.G.edges(data=True):
    print(e)
    
print("\nNodes information:")
for n in b.G.nodes(data=True):
    print(n)

"""
Explanation: You can also import the properties from a dictionary, both for nodes and edges. In the following example there are two dictionaries being created with the values of a certain property, named own_property, that will be added to brain:
End of explanation
"""


