qmm-core 0.2.2__tar.gz

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_core-0.2.2/LICENSE ADDED
@@ -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 Logo](https://github.com/jaydenhyman/qmm/blob/6f9beb2d90807e8fbc9622a4db3cdf7c0fcd06aa/logo.png) 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,76 @@
1
+ # ![QMM Logo](https://github.com/jaydenhyman/qmm/blob/6f9beb2d90807e8fbc9622a4db3cdf7c0fcd06aa/logo.png) QMM: Qualitative Mathematical Modelling
2
+
3
+ QMM is a software platform for analysing the structure and function of complex systems.
4
+
5
+ ## Features
6
+
7
+ - Interactive web application for creating signed digraph (network) models representing the mathematical structure of a complex system.
8
+ - Python package (`qmm`) for qualitative mathematical modelling, including core modules for defining model structure, stability analysis, press perturbation analysis and making qualitative predictions.
9
+
10
+ ## Contact
11
+
12
+ For any additional information or questions, please contact:
13
+
14
+ Jayden Hyman: <j.hyman@uq.edu.au>
15
+
16
+ ## How to use
17
+
18
+ 1. Install Python and required packages:
19
+
20
+ Option 1: Using Anaconda and JupyterLab (Recommended)
21
+
22
+ a. Install Anaconda from <https://www.anaconda.com/products/distribution>
23
+
24
+ b. Launch Anaconda Navigator
25
+
26
+ c. Create a new environment:
27
+ - Click on "Environments" in the left sidebar
28
+ - Click "Create" at the bottom
29
+ - Name your environment (e.g., "qmm") and select Python 3.10
30
+ - Click "Create"
31
+ - In the "Environments" list, click on your newly created environment
32
+
33
+ d. Install JupyterLab:
34
+ - With your new environment selected, go to the "Home" tab
35
+ - Find JupyterLab in the list of applications
36
+ - Click "Install"
37
+
38
+ 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.
39
+
40
+ Option 2: Using Miniconda
41
+
42
+ a. Install Miniconda from <https://docs.conda.io/en/latest/miniconda.html>
43
+
44
+ b. Open a Miniconda prompt and create a new environment:
45
+
46
+ ```bash
47
+ conda create -n qmm python=3.10
48
+ conda activate qmm
49
+ ```
50
+
51
+ c. Install the required packages:
52
+
53
+ ```bash
54
+ conda install numpy=1.26.4 networkx=3.3 pandas=2.0.2 numba=0.60.0 sympy=1.13
55
+ conda install -c conda-forge graphviz=0.20.3
56
+ ```
57
+
58
+ 2. Use the web application to create signed digraph models: [Open in browser](https://d2x70551if0frn.cloudfront.net/)
59
+
60
+ 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.
61
+
62
+ ## Documentation
63
+
64
+ Detailed documentation for the `qmm` package and its modules is not currently available.
65
+
66
+ ## Licensing
67
+
68
+ This model is licensed under a BSD 3-Clause License. See LICENSE.md for further information.
69
+
70
+ ## Attribution
71
+
72
+ A Zenodo will be available in the near future for attribution.
73
+
74
+ ## Contributing
75
+
76
+ 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,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qmm-core"
7
+ version = "0.2.2"
8
+ authors = [
9
+ { name="Jayden Hyman", email="j.hyman@uq.edu.au" },
10
+ ]
11
+ description = "A software platform for analysing the structure and function of complex systems"
12
+ readme = "README.md"
13
+ requires-python = ">=3.10"
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: BSD License",
18
+ "Programming Language :: Python :: 3.10",
19
+ ]
20
+ dependencies = [
21
+ "pytest",
22
+ "numpy",
23
+ "sympy",
24
+ "networkx",
25
+ "graphviz",
26
+ "numba",
27
+ "pandas",
28
+ "seaborn",
29
+ "matplotlib",
30
+ ]
31
+
32
+ [project.urls]
33
+ "Homepage" = "https://github.com/jaydenhyman/qmm"
34
+ "Bug Tracker" = "https://github.com/jaydenhyman/qmm/issues"
35
+
36
+ [tool.setuptools.packages.find]
37
+ exclude = ["tests*"]
38
+
39
+ [tool.setuptools.package-data]
40
+ qmm = ["py.typed"]
@@ -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
+ ]
@@ -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)