qmm-core 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qmm/__init__.py +90 -0
- qmm/core/helper.py +324 -0
- qmm/core/prediction.py +74 -0
- qmm/core/press.py +119 -0
- qmm/core/stability.py +367 -0
- qmm/core/structure.py +120 -0
- qmm_core-0.2.2.dist-info/LICENSE +28 -0
- qmm_core-0.2.2.dist-info/METADATA +100 -0
- qmm_core-0.2.2.dist-info/RECORD +11 -0
- qmm_core-0.2.2.dist-info/WHEEL +5 -0
- qmm_core-0.2.2.dist-info/top_level.txt +1 -0
qmm/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from .core.structure import (
|
|
2
|
+
import_digraph,
|
|
3
|
+
create_matrix,
|
|
4
|
+
create_equations,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from .core.stability import (
|
|
8
|
+
sign_stability,
|
|
9
|
+
system_feedback,
|
|
10
|
+
net_feedback,
|
|
11
|
+
absolute_feedback,
|
|
12
|
+
weighted_feedback,
|
|
13
|
+
feedback_metrics,
|
|
14
|
+
hurwitz_determinants,
|
|
15
|
+
net_determinants,
|
|
16
|
+
absolute_determinants,
|
|
17
|
+
weighted_determinants,
|
|
18
|
+
determinants_metrics,
|
|
19
|
+
conditional_stability,
|
|
20
|
+
simulation_stability,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .core.press import (
|
|
24
|
+
adjoint_matrix,
|
|
25
|
+
absolute_feedback_matrix,
|
|
26
|
+
weighted_predictions_matrix,
|
|
27
|
+
sign_determinacy_matrix,
|
|
28
|
+
numerical_simulations,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .core.prediction import (
|
|
32
|
+
table_of_predictions,
|
|
33
|
+
compare_predictions,
|
|
34
|
+
create_plot,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from .core.helper import (
|
|
38
|
+
list_to_digraph,
|
|
39
|
+
digraph_to_list,
|
|
40
|
+
powerplay_labels,
|
|
41
|
+
perm,
|
|
42
|
+
get_nodes,
|
|
43
|
+
get_positive,
|
|
44
|
+
get_negative,
|
|
45
|
+
get_weight,
|
|
46
|
+
sign_determinacy,
|
|
47
|
+
display_digraph,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
# structure.py
|
|
52
|
+
"import_digraph",
|
|
53
|
+
"display_digraph",
|
|
54
|
+
"create_matrix",
|
|
55
|
+
"create_equations",
|
|
56
|
+
# stability.py
|
|
57
|
+
"sign_stability",
|
|
58
|
+
"system_feedback",
|
|
59
|
+
"net_feedback",
|
|
60
|
+
"absolute_feedback",
|
|
61
|
+
"weighted_feedback",
|
|
62
|
+
"feedback_metrics",
|
|
63
|
+
"hurwitz_determinants",
|
|
64
|
+
"net_determinants",
|
|
65
|
+
"absolute_determinants",
|
|
66
|
+
"weighted_determinants",
|
|
67
|
+
"determinants_metrics",
|
|
68
|
+
"conditional_stability",
|
|
69
|
+
"simulation_stability",
|
|
70
|
+
# press.py
|
|
71
|
+
"adjoint_matrix",
|
|
72
|
+
"absolute_feedback_matrix",
|
|
73
|
+
"weighted_predictions_matrix",
|
|
74
|
+
"sign_determinacy_matrix",
|
|
75
|
+
"numerical_simulations",
|
|
76
|
+
# prediction.py
|
|
77
|
+
"table_of_predictions",
|
|
78
|
+
"compare_predictions",
|
|
79
|
+
"create_plot",
|
|
80
|
+
# helper.py
|
|
81
|
+
"list_to_digraph",
|
|
82
|
+
"digraph_to_list",
|
|
83
|
+
"powerplay_labels",
|
|
84
|
+
"perm",
|
|
85
|
+
"get_nodes",
|
|
86
|
+
"get_positive",
|
|
87
|
+
"get_negative",
|
|
88
|
+
"get_weight",
|
|
89
|
+
"sign_determinacy",
|
|
90
|
+
]
|
qmm/core/helper.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import sympy as sp
|
|
3
|
+
import networkx as nx
|
|
4
|
+
from numba import jit
|
|
5
|
+
import graphviz
|
|
6
|
+
|
|
7
|
+
def list_to_digraph(matrix, ids=None):
|
|
8
|
+
if not isinstance(matrix, (list, np.ndarray)):
|
|
9
|
+
raise ValueError("Input must be a list of lists or a numpy array")
|
|
10
|
+
if isinstance(matrix, list):
|
|
11
|
+
matrix = np.array(matrix)
|
|
12
|
+
if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]:
|
|
13
|
+
raise ValueError("Input must be a square matrix")
|
|
14
|
+
G = nx.DiGraph()
|
|
15
|
+
n = matrix.shape[0]
|
|
16
|
+
if ids is None:
|
|
17
|
+
node_ids = [str(i) for i in range(1, n + 1)]
|
|
18
|
+
else:
|
|
19
|
+
if len(ids) != n:
|
|
20
|
+
raise ValueError("Number of ids must match matrix dimensions")
|
|
21
|
+
node_ids = ids
|
|
22
|
+
G.add_nodes_from(node_ids)
|
|
23
|
+
for i in range(n):
|
|
24
|
+
for j in range(n):
|
|
25
|
+
if matrix[i][j] != 0:
|
|
26
|
+
G.add_edge(node_ids[j], node_ids[i], sign=int(matrix[i][j]))
|
|
27
|
+
nx.set_node_attributes(G, "state", "category")
|
|
28
|
+
return G
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def digraph_to_list(G):
|
|
32
|
+
if not isinstance(G, nx.DiGraph):
|
|
33
|
+
raise TypeError("Input must be a networkx.DiGraph.")
|
|
34
|
+
n = G.number_of_nodes()
|
|
35
|
+
nodes = sorted(G.nodes())
|
|
36
|
+
node_to_index = {node: i for i, node in enumerate(nodes)}
|
|
37
|
+
matrix = [[0 for _ in range(n)] for _ in range(n)]
|
|
38
|
+
for source, target, data in G.edges(data=True):
|
|
39
|
+
i, j = node_to_index[source], node_to_index[target]
|
|
40
|
+
sign = data.get("sign", 1)
|
|
41
|
+
matrix[j][i] = sign
|
|
42
|
+
return str(matrix)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def powerplay_labels(input_str):
|
|
46
|
+
return [item.split(": ")[1] for item in input_str.split(", ")]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def perm(A, method="glynn"):
|
|
50
|
+
if not isinstance(A, np.ndarray):
|
|
51
|
+
raise TypeError("Input matrix must be a NumPy array.")
|
|
52
|
+
matshape = A.shape
|
|
53
|
+
if matshape[0] != matshape[1]:
|
|
54
|
+
raise ValueError("Input matrix must be square.")
|
|
55
|
+
if np.isnan(A).any():
|
|
56
|
+
raise ValueError("Input matrix must not contain NaNs.")
|
|
57
|
+
if matshape[0] == 0:
|
|
58
|
+
return A.dtype.type(1.0)
|
|
59
|
+
if matshape[0] == 1:
|
|
60
|
+
return A[0, 0]
|
|
61
|
+
if matshape[0] == 2:
|
|
62
|
+
return A[0, 0] * A[1, 1] + A[0, 1] * A[1, 0]
|
|
63
|
+
if matshape[0] == 3:
|
|
64
|
+
return (
|
|
65
|
+
A[0, 2] * A[1, 1] * A[2, 0]
|
|
66
|
+
+ A[0, 1] * A[1, 2] * A[2, 0]
|
|
67
|
+
+ A[0, 2] * A[1, 0] * A[2, 1]
|
|
68
|
+
+ A[0, 0] * A[1, 2] * A[2, 1]
|
|
69
|
+
+ A[0, 1] * A[1, 0] * A[2, 2]
|
|
70
|
+
+ A[0, 0] * A[1, 1] * A[2, 2]
|
|
71
|
+
)
|
|
72
|
+
return _ryser(A) if method != "glynn" else _glynn(A)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@jit(nopython=True)
|
|
76
|
+
def _ryser(A):
|
|
77
|
+
n = len(A)
|
|
78
|
+
if n == 0:
|
|
79
|
+
return A.dtype.type(1.0)
|
|
80
|
+
row_comb = np.zeros((n), dtype=A.dtype)
|
|
81
|
+
total = 0
|
|
82
|
+
old_grey = 0
|
|
83
|
+
sign = +1
|
|
84
|
+
binary_power_dict = [2**i for i in range(n)]
|
|
85
|
+
num_loops = 2**n
|
|
86
|
+
for k in range(0, num_loops):
|
|
87
|
+
bin_index = (k + 1) % num_loops
|
|
88
|
+
reduced = np.prod(row_comb)
|
|
89
|
+
total += sign * reduced
|
|
90
|
+
new_grey = bin_index ^ (bin_index // 2)
|
|
91
|
+
grey_diff = old_grey ^ new_grey
|
|
92
|
+
grey_diff_index = binary_power_dict.index(grey_diff)
|
|
93
|
+
new_vector = A[grey_diff_index]
|
|
94
|
+
direction = (old_grey > new_grey) - (old_grey < new_grey)
|
|
95
|
+
for i in range(n):
|
|
96
|
+
row_comb[i] += new_vector[i] * direction
|
|
97
|
+
sign = -sign
|
|
98
|
+
old_grey = new_grey
|
|
99
|
+
return total
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@jit(nopython=True)
|
|
103
|
+
def _glynn(A):
|
|
104
|
+
n = len(A)
|
|
105
|
+
if n == 0:
|
|
106
|
+
return A.dtype.type(1.0)
|
|
107
|
+
row_comb = np.sum(A, 0)
|
|
108
|
+
total = 0
|
|
109
|
+
old_gray = 0
|
|
110
|
+
sign = +1
|
|
111
|
+
binary_power_dict = [2**i for i in range(n)]
|
|
112
|
+
num_loops = 2 ** (n - 1)
|
|
113
|
+
for bin_index in range(1, num_loops + 1):
|
|
114
|
+
reduced = np.prod(row_comb)
|
|
115
|
+
total += sign * reduced
|
|
116
|
+
new_gray = bin_index ^ (bin_index // 2)
|
|
117
|
+
gray_diff = old_gray ^ new_gray
|
|
118
|
+
gray_diff_index = binary_power_dict.index(gray_diff)
|
|
119
|
+
new_vector = A[gray_diff_index]
|
|
120
|
+
direction = 2 * ((old_gray > new_gray) - (old_gray < new_gray))
|
|
121
|
+
for i in range(n):
|
|
122
|
+
row_comb[i] += new_vector[i] * direction
|
|
123
|
+
sign = -sign
|
|
124
|
+
old_gray = new_gray
|
|
125
|
+
return total / num_loops
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_nodes(G, node_type="state", labels=False):
|
|
129
|
+
if not isinstance(G, nx.DiGraph):
|
|
130
|
+
raise TypeError("Input must be a networkx.DiGraph.")
|
|
131
|
+
|
|
132
|
+
if node_type == "all":
|
|
133
|
+
return list(G.nodes()) if not labels else list(G.nodes(data=True))
|
|
134
|
+
else:
|
|
135
|
+
return [
|
|
136
|
+
n if not labels else d.get("label", n)
|
|
137
|
+
for n, d in G.nodes(data=True)
|
|
138
|
+
if d.get("category") == node_type
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_weight(net, absolute, no_effect=sp.nan):
|
|
143
|
+
if net.shape != absolute.shape:
|
|
144
|
+
raise ValueError("Matrices must have the same shape")
|
|
145
|
+
result = sp.zeros(*net.shape)
|
|
146
|
+
for i in range(net.shape[0]):
|
|
147
|
+
for j in range(net.shape[1]):
|
|
148
|
+
if absolute[i, j] == 0:
|
|
149
|
+
result[i, j] = no_effect
|
|
150
|
+
else:
|
|
151
|
+
result[i, j] = net[i, j] / absolute[i, j]
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_positive(net, absolute):
|
|
156
|
+
if net.shape != absolute.shape:
|
|
157
|
+
raise ValueError("Matrices must have the same shape")
|
|
158
|
+
result = sp.zeros(*net.shape)
|
|
159
|
+
for i in range(net.shape[0]):
|
|
160
|
+
for j in range(net.shape[1]):
|
|
161
|
+
result[i, j] = (net[i, j] + absolute[i, j]) // 2
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_negative(net, absolute):
|
|
166
|
+
if net.shape != absolute.shape:
|
|
167
|
+
raise ValueError("Matrices must have the same shape")
|
|
168
|
+
result = sp.zeros(*net.shape)
|
|
169
|
+
for i in range(net.shape[0]):
|
|
170
|
+
for j in range(net.shape[1]):
|
|
171
|
+
result[i, j] = (absolute[i, j] - net[i, j]) // 2
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def sign_determinacy(wmat, tmat, method="average"):
|
|
176
|
+
def compute_prob(w, t, method):
|
|
177
|
+
if w == sp.Integer(0):
|
|
178
|
+
return sp.Rational(1, 2)
|
|
179
|
+
elif w == sp.Integer(1):
|
|
180
|
+
return sp.Integer(1)
|
|
181
|
+
elif w == sp.Integer(-1):
|
|
182
|
+
return sp.Integer(-1)
|
|
183
|
+
elif t == sp.Integer(0):
|
|
184
|
+
return sp.nan
|
|
185
|
+
return (
|
|
186
|
+
compute_prob_average(w, t)
|
|
187
|
+
if method == "average"
|
|
188
|
+
else compute_prob_95_bound(w, t)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def compute_prob_average(w, t):
|
|
192
|
+
bw = 3.45962
|
|
193
|
+
bwt = 0.03417
|
|
194
|
+
prob = sp.exp(bw * w + bwt * w * t) / (1 + sp.exp(bw * w + bwt * w * t))
|
|
195
|
+
return max(sp.Rational(1, 2), prob)
|
|
196
|
+
|
|
197
|
+
def compute_prob_95_bound(w, t):
|
|
198
|
+
bw = 9.766
|
|
199
|
+
bwt = 0.139
|
|
200
|
+
prob = sp.exp(bw * w + bwt * w * t) / (1253.992 + sp.exp(bw * w + bwt * w * t))
|
|
201
|
+
return max(sp.Rational(1, 2), prob)
|
|
202
|
+
|
|
203
|
+
if method not in ["average", "95_bound"]:
|
|
204
|
+
raise ValueError("Invalid method. Choose 'average' or '95_bound'.")
|
|
205
|
+
|
|
206
|
+
rows, cols = wmat.shape
|
|
207
|
+
|
|
208
|
+
def calc_prob(i, j):
|
|
209
|
+
w, t = wmat[i, j], tmat[i, j]
|
|
210
|
+
if w.is_zero:
|
|
211
|
+
return sp.Rational(1, 2)
|
|
212
|
+
prob = compute_prob(sp.Abs(w), t, method)
|
|
213
|
+
return sp.sign(w) * prob if prob is not None else sp.nan
|
|
214
|
+
|
|
215
|
+
pmat = sp.Matrix(rows, cols, lambda i, j: calc_prob(i, j))
|
|
216
|
+
return pmat
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def arrows(G, path):
|
|
220
|
+
arrows = []
|
|
221
|
+
for i in range(len(path) - 1):
|
|
222
|
+
if G[path[i]][path[i + 1]]["sign"] > 0:
|
|
223
|
+
arrows.append(f"{path[i]} $\\rightarrow$") # Right arrow
|
|
224
|
+
else:
|
|
225
|
+
arrows.append(f"{path[i]} $\\multimap$") # Multimap
|
|
226
|
+
arrows.append(str(path[-1]))
|
|
227
|
+
return " ".join(arrows)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def sign_string(G, path):
|
|
231
|
+
signs = []
|
|
232
|
+
for from_node, to_node in zip(path, path[1:]):
|
|
233
|
+
sign = G[from_node][to_node]["sign"]
|
|
234
|
+
if sign != 0:
|
|
235
|
+
signs.append(int(sign))
|
|
236
|
+
product = sp.prod(signs)
|
|
237
|
+
if product > 0:
|
|
238
|
+
return "+"
|
|
239
|
+
elif product < 0:
|
|
240
|
+
return "\u2212"
|
|
241
|
+
else:
|
|
242
|
+
return "0"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def symbolic_path(G, A, path, nodes):
|
|
246
|
+
if len(path) == 1:
|
|
247
|
+
return str(A[nodes.index(path[0]), nodes.index(path[0])])
|
|
248
|
+
return " * ".join(
|
|
249
|
+
str(A[nodes.index(path[i]), nodes.index(path[i + 1])])
|
|
250
|
+
for i in range(len(path) - 1)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def display_digraph(G, label=False, output_format="svg", self_effects=True):
|
|
256
|
+
def set_lbl(node, idx):
|
|
257
|
+
return G.nodes[node].get("label", str(idx + 1)) if label else node
|
|
258
|
+
|
|
259
|
+
def dot_node(node, idx):
|
|
260
|
+
lbl = set_lbl(node, idx)
|
|
261
|
+
x = G.nodes[node].get("x", None)
|
|
262
|
+
y = G.nodes[node].get("y", None)
|
|
263
|
+
pos = f"{x / 100},{-y / 100}!" if x is not None and y is not None else ""
|
|
264
|
+
category = G.nodes[node].get("category")
|
|
265
|
+
if category == "input":
|
|
266
|
+
shape = "rectangle"
|
|
267
|
+
size = "0.45,0.45"
|
|
268
|
+
color = "#FFD0C9" # Light red for input nodes
|
|
269
|
+
elif category == "output":
|
|
270
|
+
shape = "diamond"
|
|
271
|
+
size = "0.6,0.6"
|
|
272
|
+
color = "lightblue" # Light blue for output nodes
|
|
273
|
+
elif category == "state":
|
|
274
|
+
shape = "oval"
|
|
275
|
+
size = "0.52,0.52"
|
|
276
|
+
color = "cornsilk" # Light yellow for state nodes
|
|
277
|
+
else:
|
|
278
|
+
shape = "oval"
|
|
279
|
+
size = "0.52,0.52"
|
|
280
|
+
color = "whitesmoke" # Default color
|
|
281
|
+
wrap = "\\n" if label else ""
|
|
282
|
+
fixsz = "false" if label else "true"
|
|
283
|
+
return (
|
|
284
|
+
f' {node} [label="{lbl}{wrap}", pos="{pos}", '
|
|
285
|
+
f'shape={shape}, width={size.split(",")[0]}, '
|
|
286
|
+
f'height={size.split(",")[1]}, fixedsize={fixsz}, '
|
|
287
|
+
f'color="black", style="filled", fillcolor="{color}"];'
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def dot_edge(src, tgt, sign):
|
|
291
|
+
arrow = "normal" if sign.get("sign", 1) == 1 else "dot"
|
|
292
|
+
return f" {src} -> {tgt} [arrowhead={arrow}];"
|
|
293
|
+
|
|
294
|
+
has_positions = any(
|
|
295
|
+
"x" in G.nodes[node] and "y" in G.nodes[node] for node in G.nodes
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
dot = [
|
|
299
|
+
"digraph G {",
|
|
300
|
+
'layout = "neato";',
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
if not has_positions:
|
|
304
|
+
dot.extend(
|
|
305
|
+
[
|
|
306
|
+
'graph [overlap=false, splines=true, sep="+10"];',
|
|
307
|
+
"node [fixedsize=true];",
|
|
308
|
+
]
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
dot.extend(
|
|
312
|
+
[
|
|
313
|
+
"edge [labelangle=45, labeldistance=2.0];",
|
|
314
|
+
'edge [layer="back"];',
|
|
315
|
+
'node [layer="front"];',
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
dot.extend(
|
|
320
|
+
dot_edge(s, t, d) for s, t, d in G.edges(data=True) if self_effects or s != t
|
|
321
|
+
)
|
|
322
|
+
dot.extend(dot_node(n, i) for i, n in enumerate(G.nodes))
|
|
323
|
+
dot.append("}")
|
|
324
|
+
return graphviz.Source("\n".join(dot), format=output_format)
|
qmm/core/prediction.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import sympy as sp
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import seaborn as sns
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def table_of_predictions(M, t1=0.8, t2=1, index=None, columns=None):
|
|
9
|
+
if isinstance(M, sp.Matrix):
|
|
10
|
+
M = sp.matrix2numpy(M, dtype=float)
|
|
11
|
+
conditions = [
|
|
12
|
+
(lambda x: x == sp.nan, "0"),
|
|
13
|
+
(lambda x: x >= t2, "+"),
|
|
14
|
+
(lambda x: x >= t1 and x < t2, "(+)"),
|
|
15
|
+
(lambda x: x > -t1 and x < t1, "?"),
|
|
16
|
+
(lambda x: x > -t2 and x <= -t1, "(\u2212)"),
|
|
17
|
+
(lambda x: x <= -t2, "\u2212"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
predictions = np.empty(M.shape, dtype=object)
|
|
21
|
+
for i in range(M.shape[0]):
|
|
22
|
+
for j in range(M.shape[1]):
|
|
23
|
+
value = M[i, j]
|
|
24
|
+
predictions[i, j] = next(
|
|
25
|
+
(val for cond, val in conditions if cond(value)), "0"
|
|
26
|
+
)
|
|
27
|
+
return pd.DataFrame(predictions, index=index, columns=columns)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compare_predictions(M1, M2):
|
|
31
|
+
if not M1.index.equals(M2.index) or not M1.columns.equals(M2.columns):
|
|
32
|
+
raise ValueError("M1 and M2 must have the same index and columns")
|
|
33
|
+
combined = np.vectorize(lambda x, y: x if x == y else f"{x}, {y}")(
|
|
34
|
+
M1.values, M2.values
|
|
35
|
+
)
|
|
36
|
+
return pd.DataFrame(combined, index=M1.index, columns=M1.columns)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_plot(data, **kwargs):
|
|
40
|
+
plt.rcParams.update(
|
|
41
|
+
{
|
|
42
|
+
"xtick.top": True,
|
|
43
|
+
"xtick.bottom": False,
|
|
44
|
+
"xtick.labeltop": True,
|
|
45
|
+
"xtick.labelbottom": False,
|
|
46
|
+
"xtick.major.width": 0.5,
|
|
47
|
+
"ytick.major.width": 0.5,
|
|
48
|
+
"xtick.major.size": 3,
|
|
49
|
+
"ytick.major.size": 3,
|
|
50
|
+
"xtick.minor.size": 1.5,
|
|
51
|
+
"ytick.minor.size": 1.5,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
args = {
|
|
55
|
+
"annot": True,
|
|
56
|
+
"linewidths": 0.75,
|
|
57
|
+
"linecolor": "white",
|
|
58
|
+
"cbar": False,
|
|
59
|
+
"cmap": None,
|
|
60
|
+
}
|
|
61
|
+
args.update(kwargs)
|
|
62
|
+
figsize = args.pop("figsize", None)
|
|
63
|
+
if figsize:
|
|
64
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
65
|
+
else:
|
|
66
|
+
fig, ax = plt.subplots()
|
|
67
|
+
sns.heatmap(data, ax=ax, **args)
|
|
68
|
+
plt.setp(ax.get_xticklabels(), rotation=0, ha="center")
|
|
69
|
+
plt.setp(ax.get_yticklabels(), rotation=0, ha="right")
|
|
70
|
+
for spine in ax.spines.values():
|
|
71
|
+
spine.set_visible(True)
|
|
72
|
+
spine.set_color("white")
|
|
73
|
+
spine.set_linewidth(0.5)
|
|
74
|
+
return fig, ax
|
qmm/core/press.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import sympy as sp
|
|
3
|
+
from functools import cache
|
|
4
|
+
from .structure import create_matrix
|
|
5
|
+
from .helper import perm, get_weight, get_nodes, sign_determinacy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@cache
|
|
9
|
+
def adjoint_matrix(G, form="symbolic", perturb=None):
|
|
10
|
+
A = create_matrix(G, form=form)
|
|
11
|
+
A = sp.Matrix(-A)
|
|
12
|
+
nodes = get_nodes(G, "state")
|
|
13
|
+
n = len(nodes)
|
|
14
|
+
if perturb is not None:
|
|
15
|
+
src_id = nodes.index(perturb)
|
|
16
|
+
return sp.Matrix(
|
|
17
|
+
[sp.Integer(-1) ** (src_id + j) * A.minor(src_id, j)
|
|
18
|
+
for j in range(n)]
|
|
19
|
+
)
|
|
20
|
+
adjoint_matrix = sp.expand(A.adjugate())
|
|
21
|
+
return sp.Matrix(adjoint_matrix)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@cache
|
|
25
|
+
def absolute_feedback_matrix(G, perturb=None):
|
|
26
|
+
A = create_matrix(G, form="binary")
|
|
27
|
+
A_np = np.array(sp.matrix2numpy(A), dtype=int)
|
|
28
|
+
nodes = get_nodes(G, "state")
|
|
29
|
+
n = A_np.shape[0]
|
|
30
|
+
if perturb is not None:
|
|
31
|
+
perturb_index = nodes.index(perturb)
|
|
32
|
+
result = np.zeros(n, dtype=int)
|
|
33
|
+
for j in range(n):
|
|
34
|
+
minor = np.delete(np.delete(A_np, perturb_index, 0), j, 1)
|
|
35
|
+
result[j] = int(perm(minor.astype(float)))
|
|
36
|
+
return sp.Matrix(result)
|
|
37
|
+
tmat = np.zeros((n, n), dtype=int)
|
|
38
|
+
for i in range(n):
|
|
39
|
+
for j in range(n):
|
|
40
|
+
minor = np.delete(np.delete(A_np, j, 0), i, 1)
|
|
41
|
+
tmat[i, j] = int(perm(minor.astype(float)))
|
|
42
|
+
return sp.Matrix(tmat)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cache
|
|
46
|
+
def weighted_predictions_matrix(G, perturb=None, as_nan=True, as_abs=False):
|
|
47
|
+
amat = adjoint_matrix(G, perturb=perturb, form="signed")
|
|
48
|
+
if as_abs:
|
|
49
|
+
amat = sp.Abs(amat)
|
|
50
|
+
tmat = absolute_feedback_matrix(G, perturb=perturb)
|
|
51
|
+
if as_nan:
|
|
52
|
+
wmat = get_weight(amat, tmat)
|
|
53
|
+
else:
|
|
54
|
+
wmat = get_weight(amat, tmat, sp.Integer(1))
|
|
55
|
+
return sp.Matrix(wmat)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cache
|
|
59
|
+
def sign_determinacy_matrix(
|
|
60
|
+
G, perturb=None, method="average", as_nan=True, as_abs=False
|
|
61
|
+
):
|
|
62
|
+
wmat = weighted_predictions_matrix(
|
|
63
|
+
G, perturb=perturb, as_nan=as_nan, as_abs=as_abs
|
|
64
|
+
)
|
|
65
|
+
tmat = sp.Matrix(absolute_feedback_matrix(G, perturb=perturb))
|
|
66
|
+
pmat = sign_determinacy(wmat, tmat, method)
|
|
67
|
+
return sp.Matrix(pmat)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cache
|
|
71
|
+
def numerical_simulations(G, perturb=None, n_sim=10000, dist="uniform", seed=42, as_abs=False):
|
|
72
|
+
np.random.seed(seed)
|
|
73
|
+
A = create_matrix(G, form="symbolic", matrix_type="A")
|
|
74
|
+
state_nodes = get_nodes(G, "state")
|
|
75
|
+
node_idx = {node: i for i, node in enumerate(state_nodes)}
|
|
76
|
+
n = len(state_nodes)
|
|
77
|
+
symbols = list(A.free_symbols)
|
|
78
|
+
A_sp = sp.lambdify(symbols, A)
|
|
79
|
+
pert_idx, sign = (
|
|
80
|
+
(node_idx[perturb[0]], perturb[1])
|
|
81
|
+
if perturb else (None, 1)
|
|
82
|
+
)
|
|
83
|
+
dist_funcs = {
|
|
84
|
+
"uniform": lambda size: np.random.uniform(0, 1, size),
|
|
85
|
+
"weak": lambda size: np.random.beta(1, 3, size),
|
|
86
|
+
"moderate": lambda size: np.random.beta(2, 2, size),
|
|
87
|
+
"strong": lambda size: np.random.beta(3, 1, size),
|
|
88
|
+
}
|
|
89
|
+
positive = np.zeros((n, n), dtype=int)
|
|
90
|
+
negative = np.zeros((n, n), dtype=int)
|
|
91
|
+
total_simulations = 0
|
|
92
|
+
while total_simulations < n_sim:
|
|
93
|
+
values = dist_funcs[dist](len(symbols))
|
|
94
|
+
sim_A = A_sp(*values)
|
|
95
|
+
if np.all(np.real(np.linalg.eigvals(sim_A)) < 0):
|
|
96
|
+
try:
|
|
97
|
+
inv_A = np.linalg.inv(-sim_A)
|
|
98
|
+
effect = (
|
|
99
|
+
inv_A[:, pert_idx] * sign
|
|
100
|
+
if pert_idx is not None else inv_A
|
|
101
|
+
)
|
|
102
|
+
positive += effect > 0
|
|
103
|
+
negative += effect < 0
|
|
104
|
+
total_simulations += 1
|
|
105
|
+
except np.linalg.LinAlgError:
|
|
106
|
+
continue
|
|
107
|
+
smat = np.where(negative > positive, -negative / n_sim, positive / n_sim)
|
|
108
|
+
smat = sp.Matrix(smat)
|
|
109
|
+
tmat = absolute_feedback_matrix(G)
|
|
110
|
+
tmat_np = np.array(tmat.tolist(), dtype=bool)
|
|
111
|
+
smat = sp.Matrix(
|
|
112
|
+
[
|
|
113
|
+
[sp.nan if not tmat_np[i, j] else smat[i, j] for j in range(n)]
|
|
114
|
+
for i in range(n)
|
|
115
|
+
]
|
|
116
|
+
)
|
|
117
|
+
if as_abs:
|
|
118
|
+
smat = sp.Abs(smat)
|
|
119
|
+
return sp.Matrix(smat)
|
qmm/core/stability.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import networkx as nx
|
|
4
|
+
import sympy as sp
|
|
5
|
+
from itertools import combinations
|
|
6
|
+
from functools import cache
|
|
7
|
+
from .structure import create_matrix
|
|
8
|
+
from .helper import perm, get_positive, get_negative, get_weight
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def colour_test(G):
|
|
12
|
+
A = create_matrix(G, form="signed")
|
|
13
|
+
n = A.shape[0]
|
|
14
|
+
colour = {i: "black" if A[i, i] != 0 else "white" for i in range(n)}
|
|
15
|
+
if n <= 4 or "white" not in colour.values():
|
|
16
|
+
return "Fail"
|
|
17
|
+
else:
|
|
18
|
+
while "white" in colour.values():
|
|
19
|
+
progress_made = False
|
|
20
|
+
for i in [i for i, c in colour.items() if c == "white"]:
|
|
21
|
+
neighbours = [(j, colour[j]) for j in range(n)
|
|
22
|
+
if A[i, j] * A[j, i] < 0]
|
|
23
|
+
white_neighbours = [j for j, c in neighbours if c == "white"]
|
|
24
|
+
if not white_neighbours or any(
|
|
25
|
+
sum(
|
|
26
|
+
1
|
|
27
|
+
for k in range(n)
|
|
28
|
+
if A[j, k] * A[k, j] < 0 and colour[k] == "white"
|
|
29
|
+
)
|
|
30
|
+
<= 1
|
|
31
|
+
for j in [j for j, c in neighbours if c == "black"]
|
|
32
|
+
):
|
|
33
|
+
colour[i] = "black"
|
|
34
|
+
progress_made = True
|
|
35
|
+
break
|
|
36
|
+
if not progress_made:
|
|
37
|
+
return "Pass"
|
|
38
|
+
return "Fail"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sign_stability(G):
|
|
42
|
+
A = sp.matrix2numpy(create_matrix(G, form="signed")).astype(int)
|
|
43
|
+
n = A.shape[0]
|
|
44
|
+
conditions = [
|
|
45
|
+
all(A[i, i] <= 0 for i in range(n)),
|
|
46
|
+
any(A[i, i] < 0 for i in range(n)),
|
|
47
|
+
all(A[i, j] * A[j, i] <= 0 for i in range(n) for j in range(n) if i != j),
|
|
48
|
+
all(len(cycle) < 3 for cycle in nx.simple_cycles(nx.DiGraph(A))),
|
|
49
|
+
np.linalg.det(A) != 0,
|
|
50
|
+
]
|
|
51
|
+
colour_result = colour_test(G) == "Fail"
|
|
52
|
+
is_sign_stable = all(conditions) and colour_result
|
|
53
|
+
return pd.DataFrame(
|
|
54
|
+
{
|
|
55
|
+
"Test": [
|
|
56
|
+
"Condition i",
|
|
57
|
+
"Condition ii",
|
|
58
|
+
"Condition iii",
|
|
59
|
+
"Condition iv",
|
|
60
|
+
"Condition v",
|
|
61
|
+
"Colour test",
|
|
62
|
+
"Sign stable",
|
|
63
|
+
],
|
|
64
|
+
"Definition": [
|
|
65
|
+
"No positive self-effects",
|
|
66
|
+
"At least one node is self-regulating",
|
|
67
|
+
"The product of any pairwise interaction is non-positive",
|
|
68
|
+
"No cycles greater than length two",
|
|
69
|
+
"Non-zero determinant (all nodes have at least " +
|
|
70
|
+
"one incoming and outgoing link)",
|
|
71
|
+
"Fails Jeffries' colour test",
|
|
72
|
+
"Satisfies necessary and sufficient conditions for sign stability",
|
|
73
|
+
],
|
|
74
|
+
"Result": conditions + [colour_result] + [is_sign_stable],
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@cache
|
|
80
|
+
def system_feedback(G, level=None, form="symbolic"):
|
|
81
|
+
A = create_matrix(G, form=form)
|
|
82
|
+
if level == 0:
|
|
83
|
+
return sp.Matrix([-1])
|
|
84
|
+
n = A.shape[0]
|
|
85
|
+
lam = sp.symbols("lambda")
|
|
86
|
+
p = A.charpoly(lam).as_expr()
|
|
87
|
+
if level is None:
|
|
88
|
+
fb = [-p.coeff(lam, n - k) for k in range(n + 1)]
|
|
89
|
+
else:
|
|
90
|
+
fb = [-p.coeff(lam, n - level)]
|
|
91
|
+
return sp.Matrix(fb)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@cache
|
|
95
|
+
def net_feedback(G, level=None):
|
|
96
|
+
return system_feedback(G, level=level, form="signed")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@cache
|
|
100
|
+
def absolute_feedback(G, level=None, method="combinations"):
|
|
101
|
+
A = create_matrix(G, form="signed")
|
|
102
|
+
if level == 0:
|
|
103
|
+
return sp.Matrix([1])
|
|
104
|
+
n = A.shape[0]
|
|
105
|
+
if method == "combinations":
|
|
106
|
+
A = sp.matrix2numpy(A).astype(int)
|
|
107
|
+
A = np.abs(A)
|
|
108
|
+
if level is None:
|
|
109
|
+
fb = []
|
|
110
|
+
for k in range(n + 1):
|
|
111
|
+
fb_k = sum(
|
|
112
|
+
perm(A[np.ix_(c, c)], method="glynn")
|
|
113
|
+
for c in combinations(range(n), k)
|
|
114
|
+
)
|
|
115
|
+
fb.append(int(fb_k))
|
|
116
|
+
else:
|
|
117
|
+
fb_k = sum(
|
|
118
|
+
perm(A[np.ix_(c, c)], method="glynn")
|
|
119
|
+
for c in combinations(range(n), level)
|
|
120
|
+
)
|
|
121
|
+
fb = [int(fb_k)]
|
|
122
|
+
elif method == "polynomial":
|
|
123
|
+
lam = sp.Symbol("lambda")
|
|
124
|
+
A_abs = sp.Matrix(sp.Abs(A) + lam * sp.eye(n))
|
|
125
|
+
P = sp.per(A_abs)
|
|
126
|
+
if level is None:
|
|
127
|
+
fb = [P.coeff(lam, n - k) for k in range(n + 1)]
|
|
128
|
+
else:
|
|
129
|
+
fb = [P.coeff(lam, n - level)]
|
|
130
|
+
return sp.Matrix(fb)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@cache
|
|
134
|
+
def weighted_feedback(G, level=None):
|
|
135
|
+
net_fb = net_feedback(G, level=level)
|
|
136
|
+
tot_fb = absolute_feedback(G, level=level)
|
|
137
|
+
return get_weight(net_fb, tot_fb)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def hurwitz_matrix(fb, level):
|
|
141
|
+
fb_pos = fb * sp.Integer(-1)
|
|
142
|
+
if level == 0:
|
|
143
|
+
return sp.Matrix([fb_pos[0]])
|
|
144
|
+
H = sp.zeros(level, level)
|
|
145
|
+
for i in range(level):
|
|
146
|
+
for j in range(level):
|
|
147
|
+
index = 2 * j - i + 1
|
|
148
|
+
if 0 <= index < len(fb_pos):
|
|
149
|
+
H[i, j] = fb_pos[index]
|
|
150
|
+
return H
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@cache
|
|
154
|
+
def feedback_metrics(G):
|
|
155
|
+
net = net_feedback(G)
|
|
156
|
+
absolute = absolute_feedback(G)
|
|
157
|
+
positive = get_positive(net, absolute)
|
|
158
|
+
negative = get_negative(net, absolute)
|
|
159
|
+
weighted = weighted_feedback(G)
|
|
160
|
+
n = len(positive)
|
|
161
|
+
levels = [f"$F_{{{i}}}$" for i in range(n)]
|
|
162
|
+
|
|
163
|
+
df = {
|
|
164
|
+
"Level": levels,
|
|
165
|
+
"Net": [net[i, 0] for i in range(n)],
|
|
166
|
+
"Absolute": [absolute[i, 0] for i in range(n)],
|
|
167
|
+
"Positive": [positive[i, 0] for i in range(n)],
|
|
168
|
+
"Negative": [negative[i, 0] for i in range(n)],
|
|
169
|
+
"Weighted": [weighted[i, 0] for i in range(n)],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return pd.DataFrame(df)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@cache
|
|
176
|
+
def hurwitz_determinants(G, level=None, form="symbolic"):
|
|
177
|
+
fb = system_feedback(G, level=None, form=form)
|
|
178
|
+
n = len(fb) - 1
|
|
179
|
+
if n > 5 and form == "symbolic":
|
|
180
|
+
raise ValueError("Limited to systems with five or fewer variables.")
|
|
181
|
+
if level is None:
|
|
182
|
+
h = hurwitz_matrix(fb, n)
|
|
183
|
+
hd = sp.Matrix([sp.det(h[:k, :k]) for k in range(0, n + 1)])
|
|
184
|
+
else:
|
|
185
|
+
h = hurwitz_matrix(fb, level)
|
|
186
|
+
hd = sp.Matrix([sp.det(h[:level, :level])])
|
|
187
|
+
return sp.Matrix(hd)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@cache
|
|
191
|
+
def net_determinants(G, level=None):
|
|
192
|
+
return hurwitz_determinants(G, level=level, form="signed")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@cache
|
|
196
|
+
def absolute_determinants(G, level=None):
|
|
197
|
+
tot_fb = absolute_feedback(G)
|
|
198
|
+
n = tot_fb.shape[0] - 1
|
|
199
|
+
h = hurwitz_matrix(tot_fb, n)
|
|
200
|
+
if level is None:
|
|
201
|
+
td = [sp.Integer(1)]
|
|
202
|
+
for k in range(1, n + 1):
|
|
203
|
+
h_k = np.array(h[:k, :k].tolist(), dtype=float)
|
|
204
|
+
td.append(sp.Abs(sp.Integer(int(perm(h_k)))))
|
|
205
|
+
else:
|
|
206
|
+
if level < 0 or level > n:
|
|
207
|
+
raise ValueError(f"Level must be between 0 and {n}")
|
|
208
|
+
if level == 0:
|
|
209
|
+
td = [sp.Integer(1)]
|
|
210
|
+
else:
|
|
211
|
+
H_k = np.array(h[:level, :level].tolist(), dtype=float)
|
|
212
|
+
td = [sp.Abs(sp.Integer(int(perm(H_k))))]
|
|
213
|
+
return sp.Matrix(td)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@cache
|
|
217
|
+
def weighted_determinants(G, level=None):
|
|
218
|
+
net_det = net_determinants(G, level=level)
|
|
219
|
+
tot_det = absolute_determinants(G, level=level)
|
|
220
|
+
wgt_det = get_weight(net_det, tot_det)
|
|
221
|
+
return wgt_det
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@cache
|
|
225
|
+
def determinants_metrics(G):
|
|
226
|
+
net = net_determinants(G)
|
|
227
|
+
absolute = absolute_determinants(G)
|
|
228
|
+
weighted = weighted_determinants(G)
|
|
229
|
+
n = len(net)
|
|
230
|
+
levels = [f"$\Delta_{{{i}}}$" for i in range(n)]
|
|
231
|
+
df = {
|
|
232
|
+
"Hurwitz determinant": levels,
|
|
233
|
+
"Net": [net[i, 0] for i in range(n)],
|
|
234
|
+
"Absolute": [absolute[i, 0] for i in range(n)],
|
|
235
|
+
"Weighted": [weighted[i, 0] for i in range(n)],
|
|
236
|
+
}
|
|
237
|
+
return pd.DataFrame(df)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@cache
|
|
241
|
+
def create_model_c(n):
|
|
242
|
+
C = nx.DiGraph()
|
|
243
|
+
for i in range(n):
|
|
244
|
+
C.add_node(i)
|
|
245
|
+
for i in range(1, n):
|
|
246
|
+
C.add_edge(i - 1, i, sign=-1)
|
|
247
|
+
C.add_edge(i, i - 1, sign=1)
|
|
248
|
+
C.add_edge(n - 1, n - 1, sign=-1)
|
|
249
|
+
nx.set_node_attributes(C, "state", "category")
|
|
250
|
+
return C
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@cache
|
|
254
|
+
def conditional_stability(G):
|
|
255
|
+
A = create_matrix(G, form="signed")
|
|
256
|
+
n = A.shape[0]
|
|
257
|
+
w_fb = weighted_feedback(G)
|
|
258
|
+
w_det = weighted_determinants(G, level=n - 1)[0]
|
|
259
|
+
C = create_model_c(n)
|
|
260
|
+
w_det_c = weighted_determinants(C, level=n - 1)[0]
|
|
261
|
+
ratio_C = w_det / w_det_c
|
|
262
|
+
max_fb_n = np.max(w_fb) == w_fb[-1]
|
|
263
|
+
kmax = len(w_fb) - 1 - np.argmax(w_fb[::-1])
|
|
264
|
+
is_sign_stable = sign_stability(G)["Result"].iloc[-1]
|
|
265
|
+
if is_sign_stable:
|
|
266
|
+
model_class = "Sign stable"
|
|
267
|
+
elif max_fb_n and ratio_C >= 1:
|
|
268
|
+
model_class = "Class I"
|
|
269
|
+
else:
|
|
270
|
+
model_class = "Class II"
|
|
271
|
+
stability_metrics = pd.DataFrame(
|
|
272
|
+
{
|
|
273
|
+
"Test": [
|
|
274
|
+
"Weighted feedback",
|
|
275
|
+
"Weighted determinant",
|
|
276
|
+
"Ratio to model-c system",
|
|
277
|
+
"Model class",
|
|
278
|
+
],
|
|
279
|
+
"Definition": [
|
|
280
|
+
f"Maximum weighted feedback (level {kmax})",
|
|
281
|
+
"n-1 weighted determinant at level",
|
|
282
|
+
"Ratio to a 'model-c' type system",
|
|
283
|
+
"Class of the model based on conditional stability metrics",
|
|
284
|
+
],
|
|
285
|
+
"Result": [
|
|
286
|
+
np.max(w_fb).evalf(2),
|
|
287
|
+
w_det.evalf(2),
|
|
288
|
+
ratio_C.evalf(2),
|
|
289
|
+
model_class,
|
|
290
|
+
],
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
return stability_metrics
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@cache
|
|
297
|
+
def simulation_stability(G, n_sim=10000):
|
|
298
|
+
A = create_matrix(G, "signed")
|
|
299
|
+
A = sp.matrix2numpy(A).astype(int)
|
|
300
|
+
n_stable = 0
|
|
301
|
+
n_unstable = 0
|
|
302
|
+
n_hurwitz_i_fail = 0
|
|
303
|
+
n_hurwitz_ii_fail = 0
|
|
304
|
+
n_hurwitz_i_only_fail = 0
|
|
305
|
+
n_hurwitz_ii_only_fail = 0
|
|
306
|
+
for _ in range(n_sim):
|
|
307
|
+
M = np.random.rand(*A.shape)
|
|
308
|
+
S = A * M
|
|
309
|
+
if np.all(np.real(np.linalg.eigvals(S)) < 0):
|
|
310
|
+
n_stable += 1
|
|
311
|
+
else:
|
|
312
|
+
n_unstable += 1
|
|
313
|
+
pc = np.poly(S)
|
|
314
|
+
hurwitz_i = np.all(pc[1:] > 0) or np.all(pc[1:] < 0)
|
|
315
|
+
n = len(pc)
|
|
316
|
+
H = np.zeros((n - 1, n - 1))
|
|
317
|
+
for i in range(1, n):
|
|
318
|
+
for j in range(1, n):
|
|
319
|
+
index = 2 * j - i
|
|
320
|
+
if 0 <= index < n:
|
|
321
|
+
H[i - 1, j - 1] = pc[index]
|
|
322
|
+
hd = [np.linalg.det(H[: k + 1, : k + 1]) for k in range(n - 1)]
|
|
323
|
+
hurwitz_ii = np.all(np.array(hd[1:-1]) > 0)
|
|
324
|
+
if not hurwitz_i:
|
|
325
|
+
n_hurwitz_i_fail += 1
|
|
326
|
+
if hurwitz_ii:
|
|
327
|
+
n_hurwitz_i_only_fail += 1
|
|
328
|
+
if not hurwitz_ii:
|
|
329
|
+
n_hurwitz_ii_fail += 1
|
|
330
|
+
if hurwitz_i:
|
|
331
|
+
n_hurwitz_ii_only_fail += 1
|
|
332
|
+
prop_stable = n_stable / n_sim
|
|
333
|
+
prop_unstable = n_unstable / n_sim
|
|
334
|
+
prop_hurwitz_i_fail = n_hurwitz_i_fail / n_sim
|
|
335
|
+
prop_hurwitz_ii_fail = n_hurwitz_ii_fail / n_sim
|
|
336
|
+
prop_hurwitz_i_only_fail = n_hurwitz_i_only_fail / n_sim
|
|
337
|
+
prop_hurwitz_ii_only_fail = n_hurwitz_ii_only_fail / n_sim
|
|
338
|
+
sim_df = pd.DataFrame(
|
|
339
|
+
{
|
|
340
|
+
"Test": [
|
|
341
|
+
"Stable matrices",
|
|
342
|
+
"Unstable matrices",
|
|
343
|
+
"Hurwitz criterion i",
|
|
344
|
+
"Hurwitz criterion ii",
|
|
345
|
+
"Hurwitz criterion i only",
|
|
346
|
+
"Hurwitz criterion ii only",
|
|
347
|
+
],
|
|
348
|
+
"Definition": [
|
|
349
|
+
"Proportion where all eigenvalues have negative real parts",
|
|
350
|
+
"Proportion where one or more eigenvalues have positive real parts",
|
|
351
|
+
"Proportion where polynomial coefficients are not " +
|
|
352
|
+
"all of the same sign",
|
|
353
|
+
"Proportion where Hurwitz determinants are not all positive",
|
|
354
|
+
"Proportion where only Hurwitz criterion i fails",
|
|
355
|
+
"Proportion where only Hurwitz criterion ii fails",
|
|
356
|
+
],
|
|
357
|
+
"Result": [
|
|
358
|
+
f"{prop_stable:.2%}",
|
|
359
|
+
f"{prop_unstable:.2%}",
|
|
360
|
+
f"{prop_hurwitz_i_fail:.2%}",
|
|
361
|
+
f"{prop_hurwitz_ii_fail:.2%}",
|
|
362
|
+
f"{prop_hurwitz_i_only_fail:.2%}",
|
|
363
|
+
f"{prop_hurwitz_ii_only_fail:.2%}",
|
|
364
|
+
],
|
|
365
|
+
}
|
|
366
|
+
)
|
|
367
|
+
return sim_df
|
qmm/core/structure.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import networkx as nx
|
|
3
|
+
import sympy as sp
|
|
4
|
+
from .helper import get_nodes
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def import_digraph(file_path):
|
|
8
|
+
with open(file_path, "r") as file:
|
|
9
|
+
data = json.load(file)
|
|
10
|
+
G = nx.DiGraph()
|
|
11
|
+
for node in data["nodes"]:
|
|
12
|
+
att = {k: v for k, v in node.items() if k != "id"}
|
|
13
|
+
G.add_node(node["id"], **att)
|
|
14
|
+
for edge in data["edges"]:
|
|
15
|
+
source = edge["from"]
|
|
16
|
+
target = edge["to"]
|
|
17
|
+
att = {k: v for k, v in edge.items() if k not in ["from", "to", "arrows"]}
|
|
18
|
+
arr = edge.get("arrows", {}).get("to", {})
|
|
19
|
+
if isinstance(arr, dict):
|
|
20
|
+
arr_type = arr.get("type")
|
|
21
|
+
if arr_type == "triangle":
|
|
22
|
+
att["sign"] = 1
|
|
23
|
+
elif arr_type == "circle":
|
|
24
|
+
att["sign"] = -1
|
|
25
|
+
G.add_edge(source, target, **att)
|
|
26
|
+
nx.set_node_attributes(G, "state", "category")
|
|
27
|
+
return G
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_matrix(G, form="symbolic", matrix_type="A"):
|
|
31
|
+
if not isinstance(G, nx.DiGraph):
|
|
32
|
+
raise TypeError("Input must be a networkx.DiGraph.")
|
|
33
|
+
if form not in ["symbolic", "signed", "binary"]:
|
|
34
|
+
raise ValueError("Invalid form. Choose 'symbolic', 'signed', or 'binary'.")
|
|
35
|
+
if matrix_type not in ["A", "B", "C", "D"]:
|
|
36
|
+
raise ValueError("Invalid matrix_type. Choose 'A', 'B', 'C', or 'D'.")
|
|
37
|
+
|
|
38
|
+
def sym(source, target, prefix):
|
|
39
|
+
return sp.Symbol(f"{prefix}_{target},{source}")
|
|
40
|
+
|
|
41
|
+
def sign(source, target, prefix):
|
|
42
|
+
if form == "symbolic":
|
|
43
|
+
return sym(source, target, prefix) * G[source][target].get("sign", 1)
|
|
44
|
+
elif form == "signed":
|
|
45
|
+
return G[source][target].get("sign", 1)
|
|
46
|
+
else: # form == 'binary'
|
|
47
|
+
return int(G.has_edge(source, target))
|
|
48
|
+
|
|
49
|
+
def product(path):
|
|
50
|
+
effect = 1
|
|
51
|
+
for i in range(len(path) - 1):
|
|
52
|
+
effect *= sign(path[i], path[i + 1], prefix)
|
|
53
|
+
return effect
|
|
54
|
+
|
|
55
|
+
state_n = get_nodes(G, "state")
|
|
56
|
+
input_n = get_nodes(G, "input")
|
|
57
|
+
output_n = get_nodes(G, "output")
|
|
58
|
+
matrix_configs = {
|
|
59
|
+
"A": (state_n, state_n, "a", "state"),
|
|
60
|
+
"B": (state_n, input_n, "b", "input"),
|
|
61
|
+
"C": (output_n, state_n, "c", "output"),
|
|
62
|
+
"D": (output_n, input_n, "d", "input"),
|
|
63
|
+
}
|
|
64
|
+
rows, cols, prefix, category = matrix_configs[matrix_type]
|
|
65
|
+
matrix = sp.zeros(len(rows), len(cols))
|
|
66
|
+
for i, target in enumerate(rows):
|
|
67
|
+
for j, source in enumerate(cols):
|
|
68
|
+
if matrix_type == "A":
|
|
69
|
+
if G.has_edge(source, target):
|
|
70
|
+
matrix[i, j] = sign(source, target, prefix)
|
|
71
|
+
else:
|
|
72
|
+
paths = nx.all_simple_paths(G, source, target)
|
|
73
|
+
valid = [
|
|
74
|
+
p
|
|
75
|
+
for p in paths
|
|
76
|
+
if all(G.nodes[n]["category"] == category for n in p[1:-1])
|
|
77
|
+
]
|
|
78
|
+
matrix[i, j] = sum(product(path) for path in valid)
|
|
79
|
+
return matrix
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_equations(G, form="state", density_independent=None):
|
|
83
|
+
if not isinstance(G, nx.DiGraph):
|
|
84
|
+
raise TypeError("Input must be a networkx.DiGraph.")
|
|
85
|
+
A = create_matrix(G, form="symbolic", matrix_type="A")
|
|
86
|
+
B = create_matrix(G, form="symbolic", matrix_type="B")
|
|
87
|
+
C = create_matrix(G, form="symbolic", matrix_type="C")
|
|
88
|
+
D = create_matrix(G, form="symbolic", matrix_type="D")
|
|
89
|
+
state_n = get_nodes(G, "state")
|
|
90
|
+
input_n = get_nodes(G, "input")
|
|
91
|
+
output_n = get_nodes(G, "output")
|
|
92
|
+
x = sp.Matrix([sp.Symbol(f"x_{i}") for i in state_n])
|
|
93
|
+
u = sp.Matrix([sp.Symbol(f"u_{i}") for i in input_n]) if input_n else None
|
|
94
|
+
y = sp.Matrix([sp.Symbol(f"y_{i}") for i in output_n]) if output_n else None
|
|
95
|
+
if density_independent is None:
|
|
96
|
+
k = sp.zeros(A.shape[0], 1)
|
|
97
|
+
else:
|
|
98
|
+
if len(density_independent) != A.shape[0]:
|
|
99
|
+
raise ValueError(f"The length of k must be equal to {A.shape[0]}")
|
|
100
|
+
k = sp.Matrix(density_independent)
|
|
101
|
+
state_equations = x.multiply_elementwise(A * x + k)
|
|
102
|
+
if B.shape[1] > 0 and u is not None:
|
|
103
|
+
state_equations += B * u
|
|
104
|
+
output_equations = None
|
|
105
|
+
if C.shape[0] > 0:
|
|
106
|
+
output_equations = C * x
|
|
107
|
+
if D.shape[1] > 0 and u is not None:
|
|
108
|
+
output_equations += D * u
|
|
109
|
+
if form == "vector":
|
|
110
|
+
return A, B, C, D, x, u, y, k
|
|
111
|
+
elif form == "state":
|
|
112
|
+
return state_equations
|
|
113
|
+
elif form == "output":
|
|
114
|
+
if output_equations is None:
|
|
115
|
+
print("No output equations available.")
|
|
116
|
+
return output_equations
|
|
117
|
+
elif form == "jacobian":
|
|
118
|
+
return state_equations.jacobian(x)
|
|
119
|
+
else:
|
|
120
|
+
raise ValueError("Choose 'vector', 'state', 'output' or 'jacobian'.")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024, Jayden Hyman
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: qmm-core
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: A software platform for analysing the structure and function of complex systems
|
|
5
|
+
Author-email: Jayden Hyman <j.hyman@uq.edu.au>
|
|
6
|
+
Project-URL: Homepage, https://github.com/jaydenhyman/qmm
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/jaydenhyman/qmm/issues
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: pytest
|
|
16
|
+
Requires-Dist: numpy
|
|
17
|
+
Requires-Dist: sympy
|
|
18
|
+
Requires-Dist: networkx
|
|
19
|
+
Requires-Dist: graphviz
|
|
20
|
+
Requires-Dist: numba
|
|
21
|
+
Requires-Dist: pandas
|
|
22
|
+
Requires-Dist: seaborn
|
|
23
|
+
Requires-Dist: matplotlib
|
|
24
|
+
|
|
25
|
+
#  QMM: Qualitative Mathematical Modelling
|
|
26
|
+
|
|
27
|
+
QMM is a software platform for analysing the structure and function of complex systems.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Interactive web application for creating signed digraph (network) models representing the mathematical structure of a complex system.
|
|
32
|
+
- Python package (`qmm`) for qualitative mathematical modelling, including core modules for defining model structure, stability analysis, press perturbation analysis and making qualitative predictions.
|
|
33
|
+
|
|
34
|
+
## Contact
|
|
35
|
+
|
|
36
|
+
For any additional information or questions, please contact:
|
|
37
|
+
|
|
38
|
+
Jayden Hyman: <j.hyman@uq.edu.au>
|
|
39
|
+
|
|
40
|
+
## How to use
|
|
41
|
+
|
|
42
|
+
1. Install Python and required packages:
|
|
43
|
+
|
|
44
|
+
Option 1: Using Anaconda and JupyterLab (Recommended)
|
|
45
|
+
|
|
46
|
+
a. Install Anaconda from <https://www.anaconda.com/products/distribution>
|
|
47
|
+
|
|
48
|
+
b. Launch Anaconda Navigator
|
|
49
|
+
|
|
50
|
+
c. Create a new environment:
|
|
51
|
+
- Click on "Environments" in the left sidebar
|
|
52
|
+
- Click "Create" at the bottom
|
|
53
|
+
- Name your environment (e.g., "qmm") and select Python 3.10
|
|
54
|
+
- Click "Create"
|
|
55
|
+
- In the "Environments" list, click on your newly created environment
|
|
56
|
+
|
|
57
|
+
d. Install JupyterLab:
|
|
58
|
+
- With your new environment selected, go to the "Home" tab
|
|
59
|
+
- Find JupyterLab in the list of applications
|
|
60
|
+
- Click "Install"
|
|
61
|
+
|
|
62
|
+
This method uses Anaconda's default packages, which include NumPy, SymPy, NetworkX, Pandas, Numba, and Graphviz. JupyterLab provides an integrated development environment for running QMM package functions.
|
|
63
|
+
|
|
64
|
+
Option 2: Using Miniconda
|
|
65
|
+
|
|
66
|
+
a. Install Miniconda from <https://docs.conda.io/en/latest/miniconda.html>
|
|
67
|
+
|
|
68
|
+
b. Open a Miniconda prompt and create a new environment:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
conda create -n qmm python=3.10
|
|
72
|
+
conda activate qmm
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
c. Install the required packages:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
conda install numpy=1.26.4 networkx=3.3 pandas=2.0.2 numba=0.60.0 sympy=1.13
|
|
79
|
+
conda install -c conda-forge graphviz=0.20.3
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
2. Use the web application to create signed digraph models: [Open in browser](https://d2x70551if0frn.cloudfront.net/)
|
|
83
|
+
|
|
84
|
+
3. The `qmm.ipynb` file provides core functions to analyse signed digraph models. To get started with analysing your model, open this file in JupyterLab or your preferred Python IDE.
|
|
85
|
+
|
|
86
|
+
## Documentation
|
|
87
|
+
|
|
88
|
+
Detailed documentation for the `qmm` package and its modules is not currently available.
|
|
89
|
+
|
|
90
|
+
## Licensing
|
|
91
|
+
|
|
92
|
+
This model is licensed under a BSD 3-Clause License. See LICENSE.md for further information.
|
|
93
|
+
|
|
94
|
+
## Attribution
|
|
95
|
+
|
|
96
|
+
A Zenodo will be available in the near future for attribution.
|
|
97
|
+
|
|
98
|
+
## Contributing
|
|
99
|
+
|
|
100
|
+
We welcome contributions to improve and expand the QMM software. As the project is in its early stages of development, we appreciate your patience and support in helping us refine the software.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
qmm/__init__.py,sha256=SVnmNEHf91LBaitoJ2H_sJlcoEGvN6ocSy2PZAq68O4,1940
|
|
2
|
+
qmm/core/helper.py,sha256=9EsVxZIGw3ZObW2aqDy5W2x8auDLv5C7ucemI7KBRdY,10198
|
|
3
|
+
qmm/core/prediction.py,sha256=xo4XqgBECkrx4QmEJVZmlSclkTRzUz9jAUwqEs-d1KM,2315
|
|
4
|
+
qmm/core/press.py,sha256=wWXUDSRuL4xNbUof3C0MGz1dXv71NXsvbwctS7_W6NQ,3958
|
|
5
|
+
qmm/core/stability.py,sha256=JDuzumGFl3Ul6nodBfhyF5Ccadd3r4SZcoPsdU4bXHs,11764
|
|
6
|
+
qmm/core/structure.py,sha256=CIaSUhdN7eb1Nl6nMREF7RsO2w3Oy9FWJYcVruw5IYQ,4598
|
|
7
|
+
qmm_core-0.2.2.dist-info/LICENSE,sha256=9BC7XxEa_nD9uc8dHjJG-FuPTA0DllmuJTOPAUYJtrc,1527
|
|
8
|
+
qmm_core-0.2.2.dist-info/METADATA,sha256=cdm4Kipj266EmF5ZMY4QXOCFnq79YU_r1hVaJ0NZkRU,3654
|
|
9
|
+
qmm_core-0.2.2.dist-info/WHEEL,sha256=cVxcB9AmuTcXqmwrtPhNK88dr7IR_b6qagTj0UvIEbY,91
|
|
10
|
+
qmm_core-0.2.2.dist-info/top_level.txt,sha256=Nl8juSbthZpsufyisqeDeXVq1jnpHeADFAW0_vN85cg,4
|
|
11
|
+
qmm_core-0.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qmm
|