pyBondGraph 0.1.0__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.
- pyBondGraph/__init__.py +38 -0
- pyBondGraph/bondgraph.py +399 -0
- pyBondGraph/core.py +188 -0
- pyBondGraph/elements.py +359 -0
- pyBondGraph/sensors.py +86 -0
- pybondgraph-0.1.0.dist-info/METADATA +170 -0
- pybondgraph-0.1.0.dist-info/RECORD +10 -0
- pybondgraph-0.1.0.dist-info/WHEEL +5 -0
- pybondgraph-0.1.0.dist-info/licenses/LICENSE +1 -0
- pybondgraph-0.1.0.dist-info/top_level.txt +1 -0
pyBondGraph/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from .core import Bond, Causality
|
|
2
|
+
from .elements import (
|
|
3
|
+
SourceEffort,
|
|
4
|
+
SourceFlow,
|
|
5
|
+
OneJunction,
|
|
6
|
+
ZeroJunction,
|
|
7
|
+
Capacitor,
|
|
8
|
+
Compliance,
|
|
9
|
+
Inductor,
|
|
10
|
+
Inertance,
|
|
11
|
+
Resistor,
|
|
12
|
+
Resistance,
|
|
13
|
+
Transformer,
|
|
14
|
+
Gyrator,
|
|
15
|
+
)
|
|
16
|
+
from .sensors import IntegratedEffortSensor, IntegratedFlowSensor
|
|
17
|
+
|
|
18
|
+
from .bondgraph import BondGraph
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Bond",
|
|
22
|
+
"Causality",
|
|
23
|
+
"SourceEffort",
|
|
24
|
+
"SourceFlow",
|
|
25
|
+
"OneJunction",
|
|
26
|
+
"ZeroJunction",
|
|
27
|
+
"Capacitor",
|
|
28
|
+
"Compliance",
|
|
29
|
+
"Inductor",
|
|
30
|
+
"Inertance",
|
|
31
|
+
"Resistor",
|
|
32
|
+
"Resistance",
|
|
33
|
+
"Transformer",
|
|
34
|
+
"Gyrator",
|
|
35
|
+
"BondGraph",
|
|
36
|
+
"IntegratedEffortSensor",
|
|
37
|
+
"IntegratedFlowSensor",
|
|
38
|
+
]
|
pyBondGraph/bondgraph.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import sympy as sp
|
|
2
|
+
import networkx as nx
|
|
3
|
+
import numpy as np
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from .core import (
|
|
9
|
+
Causality,
|
|
10
|
+
Node,
|
|
11
|
+
StatefulElement,
|
|
12
|
+
Bond,
|
|
13
|
+
ElementOnePort,
|
|
14
|
+
ElementTwoPort,
|
|
15
|
+
Junction,
|
|
16
|
+
)
|
|
17
|
+
from .elements import SourceEffort, SourceFlow, OneJunction, ZeroJunction
|
|
18
|
+
|
|
19
|
+
type SolutionType = dict[sp.Expr, sp.Expr]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BondGraph:
|
|
23
|
+
"""Represents a bond graph consisting of elements and the bonds that connect them."""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
"""Initializes a new bond graph without any elements or bonds."""
|
|
27
|
+
|
|
28
|
+
Bond.counter = 0 # Reset bond counter for future bond graphs after solution has been computed --> ToDo: fix this hacky solution
|
|
29
|
+
|
|
30
|
+
self.elements = []
|
|
31
|
+
self.bonds: list[Bond] = []
|
|
32
|
+
|
|
33
|
+
self.state_vars: list[sp.Expr] = []
|
|
34
|
+
self.equations: list[sp.Expr] = []
|
|
35
|
+
self.inputs: list[sp.Symbol] = []
|
|
36
|
+
|
|
37
|
+
self.solution: SolutionType = None
|
|
38
|
+
|
|
39
|
+
def add_bond(self, bond: Bond) -> None:
|
|
40
|
+
"""Adds a bond to the bond graph by appending it to `self.bonds`.
|
|
41
|
+
This adds the connected elements to `self.elements` if they are not already present.
|
|
42
|
+
If the added bond connects to a `SourceEffort` or `SourceFlow`, its value is added to `self.inputs`.
|
|
43
|
+
This is needed for generating the state space representation.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
bond : Bond
|
|
48
|
+
The bond to be added to the bond graph.
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
ValueError
|
|
53
|
+
If the bond is already part of the bond graph.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
if bond in self.bonds:
|
|
57
|
+
raise ValueError(f"Bond {bond} is already part of the bond graph.")
|
|
58
|
+
|
|
59
|
+
self.bonds.append(bond)
|
|
60
|
+
|
|
61
|
+
for element in bond.elements:
|
|
62
|
+
if element in self.elements:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
self.elements.append(element)
|
|
66
|
+
|
|
67
|
+
if isinstance(element, SourceEffort) or isinstance(element, SourceFlow):
|
|
68
|
+
self.inputs.append(element.value)
|
|
69
|
+
|
|
70
|
+
def __handle_bonds(self) -> None:
|
|
71
|
+
"""Handles the bonds in the bond graph by assigning them to the appropriate elements.
|
|
72
|
+
This assignment propagates the bond references to the elements, so that each element knows which bonds it is connected to.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def handle_bond_element(element: Node, bond: Bond):
|
|
76
|
+
"""Internal helper function to handle the assignment of a bond to an element.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
element : Node
|
|
81
|
+
Element of the bond to handle. Must be called with both `from_element` and `to_element` of the bond.
|
|
82
|
+
bond : Bond
|
|
83
|
+
The bond to assign to the element.
|
|
84
|
+
|
|
85
|
+
Raises
|
|
86
|
+
------
|
|
87
|
+
ValueError
|
|
88
|
+
If the element is a junction and it is attemped to add a second strong bond.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
if isinstance(element, ElementOnePort):
|
|
92
|
+
element.bond = bond
|
|
93
|
+
|
|
94
|
+
elif isinstance(element, ElementTwoPort):
|
|
95
|
+
if bond.to_element == element:
|
|
96
|
+
element.bond1 = bond
|
|
97
|
+
elif bond.from_element == element:
|
|
98
|
+
element.bond2 = bond
|
|
99
|
+
|
|
100
|
+
elif isinstance(element, Junction):
|
|
101
|
+
element.bonds.append(bond)
|
|
102
|
+
|
|
103
|
+
if isinstance(element, OneJunction):
|
|
104
|
+
if (bond.from_element == element and bond.causality == Causality.EFFORT_OUT) or (bond.to_element == element and bond.causality == Causality.FLOW_OUT):
|
|
105
|
+
# bond is strong bond for one junction
|
|
106
|
+
if element.strong_bond is None:
|
|
107
|
+
element.strong_bond = bond
|
|
108
|
+
print(f"Assigned strong bond {bond} to OneJunction {element}.")
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError(f"OneJunction {element} already has a strong bond: {element.strong_bond}. Cannot assign {bond}.")
|
|
111
|
+
|
|
112
|
+
elif isinstance(element, ZeroJunction):
|
|
113
|
+
if (bond.from_element == element and bond.causality == Causality.FLOW_OUT) or (bond.to_element == element and bond.causality == Causality.EFFORT_OUT):
|
|
114
|
+
# bond is strong bond for zero junction
|
|
115
|
+
if element.strong_bond is None:
|
|
116
|
+
element.strong_bond = bond
|
|
117
|
+
print(f"Assigned strong bond {bond} to ZeroJunction {element}.")
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError(f"ZeroJunction {element} already has a strong bond: {element.strong_bond}. Cannot assign {bond}.")
|
|
120
|
+
|
|
121
|
+
# Call helper function for both elements of each bond
|
|
122
|
+
for bond in self.bonds:
|
|
123
|
+
handle_bond_element(bond.from_element, bond)
|
|
124
|
+
handle_bond_element(bond.to_element, bond)
|
|
125
|
+
|
|
126
|
+
def __handle_equations(self) -> None:
|
|
127
|
+
"""Accumulates the equations from all elements and junctions in the bond graph.
|
|
128
|
+
Also collects the state variables from all stateful elements.
|
|
129
|
+
|
|
130
|
+
Raises
|
|
131
|
+
------
|
|
132
|
+
ValueError
|
|
133
|
+
If an element is not fully connected with bonds.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
for element in self.elements:
|
|
137
|
+
if isinstance(element, ElementOnePort):
|
|
138
|
+
bond = element.bond
|
|
139
|
+
if bond is None:
|
|
140
|
+
raise ValueError(f"Element {element} has no connected bond.")
|
|
141
|
+
|
|
142
|
+
# Add equations from the element to the bond graph
|
|
143
|
+
self.equations.extend(element.equations)
|
|
144
|
+
|
|
145
|
+
if isinstance(element, StatefulElement):
|
|
146
|
+
self.state_vars.append(element.state_var)
|
|
147
|
+
|
|
148
|
+
elif isinstance(element, ElementTwoPort):
|
|
149
|
+
bond1 = element.bond1
|
|
150
|
+
bond2 = element.bond2
|
|
151
|
+
if bond1 is None or bond2 is None:
|
|
152
|
+
raise ValueError(f"Element {element} has no connected bonds.")
|
|
153
|
+
|
|
154
|
+
# Add equations from the element to the bond graph
|
|
155
|
+
self.equations.extend(element.equations)
|
|
156
|
+
|
|
157
|
+
elif isinstance(element, Junction):
|
|
158
|
+
self.equations.extend(element.equations)
|
|
159
|
+
|
|
160
|
+
def get_solution_equations(self) -> SolutionType:
|
|
161
|
+
"""Returns the symbolic equations defining the solution of the bond graph.
|
|
162
|
+
The solution is computed by solving the accumulated equations of the bond graph symbolically using `sympy.solve`.
|
|
163
|
+
The bond counter is reset after computation to allow for future bond graphs to be created without conflicts.
|
|
164
|
+
This is a temporary workaround and should be (urgently) improved in the future.
|
|
165
|
+
|
|
166
|
+
Returns
|
|
167
|
+
-------
|
|
168
|
+
SolutionType
|
|
169
|
+
A dictionary mapping each symbolic variable to its solved expression.
|
|
170
|
+
The keys include the time derivatives of the state variables and the efforts and flows of all bonds.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
self.__handle_bonds()
|
|
174
|
+
self.__handle_equations()
|
|
175
|
+
Bond.counter = 0 # Reset bond counter for future bond graphs after solution has been computed --> ToDo: fix this hacky solution
|
|
176
|
+
|
|
177
|
+
state_derivatives = [sp.Derivative(var, "t") for var in self.state_vars]
|
|
178
|
+
self.solution = sp.solve(
|
|
179
|
+
self.equations,
|
|
180
|
+
state_derivatives
|
|
181
|
+
+ [b.effort for b in self.bonds]
|
|
182
|
+
+ [b.flow for b in self.bonds],
|
|
183
|
+
)
|
|
184
|
+
return self.solution
|
|
185
|
+
|
|
186
|
+
def get_state_space(self) -> tuple[sp.Matrix, sp.Matrix, sp.Matrix, sp.Matrix, sp.Matrix, int, int, int]:
|
|
187
|
+
"""Calculates the linear state space representation of the bond graph.
|
|
188
|
+
This method automatically calls `get_solution_equations` if the solution has not yet been computed.
|
|
189
|
+
|
|
190
|
+
Depending on the number of state variables, inputs, and outputs, the state space representation is either SISO, MIMO or a hybrid.
|
|
191
|
+
The general form of a (nonlinear) state space model is given by the equations:
|
|
192
|
+
x_dot = f(x, u)
|
|
193
|
+
y = h(x, u)
|
|
194
|
+
where x is the state vector, u is the input vector, and y is the output vector.
|
|
195
|
+
As the bond graph framework currently only supports linear elements, the state space representation can be simplified to a linear form.
|
|
196
|
+
The linear state space representation is given by the matrices A, B, C, D in the equations:
|
|
197
|
+
x_dot = A*x + B*u
|
|
198
|
+
y = C*x + D*u
|
|
199
|
+
The matrices are computed by taking the Jacobians of the functions f and h with respect to the state variables and inputs.
|
|
200
|
+
The output y is defined to be all efforts and flows of all bonds in the bond graph, with the efforts coming first and then the flows.
|
|
201
|
+
The input u is defined to be all sources (i.e. `SourceEffort` and `SourceFlow` elements) in the bond graph.
|
|
202
|
+
The state variables x are defined to be the state variables of all `StatefulElement` elements in the bond graph.
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
tuple[sp.Matrix, sp.Matrix, sp.Matrix, sp.Matrix, sp.Matrix, int, int, int]
|
|
207
|
+
Returns the matrices A, B, C, D of the state space representation, the state vector x,
|
|
208
|
+
as well as the number of states, inputs, and outputs.
|
|
209
|
+
A in R^(n_states x n_states)
|
|
210
|
+
B in R^(n_states x n_inputs)
|
|
211
|
+
C in R^(n_outputs x n_states)
|
|
212
|
+
D in R^(n_outputs x n_inputs)
|
|
213
|
+
x in R^(n_states x 1)
|
|
214
|
+
|
|
215
|
+
Raises
|
|
216
|
+
------
|
|
217
|
+
ValueError
|
|
218
|
+
If the system of equations for this bond graph could not be solved.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
# Retrieve solution if needed
|
|
222
|
+
if self.solution is None:
|
|
223
|
+
if not self.get_solution_equations():
|
|
224
|
+
raise ValueError("Could not compute solution.")
|
|
225
|
+
|
|
226
|
+
n_states = len(self.state_vars)
|
|
227
|
+
n_inputs = len(self.inputs) # Number of inputs (sources)
|
|
228
|
+
n_outputs = 2 * len(self.bonds) # effort & flow for each bond
|
|
229
|
+
|
|
230
|
+
# General form of a state space model
|
|
231
|
+
# x_dot = f(x, u)
|
|
232
|
+
# y = h(x, u)
|
|
233
|
+
# Simplification for linear systems:
|
|
234
|
+
# x_dot = A*x + B*u
|
|
235
|
+
# y = C*x + D*u
|
|
236
|
+
# --> therefore
|
|
237
|
+
# A = ∂f/∂x, B = ∂f/∂u, C = ∂h/∂x, D = ∂h/∂u each at stationary point 0
|
|
238
|
+
|
|
239
|
+
f: sp.Matrix = sp.zeros(n_states, 1)
|
|
240
|
+
for i, state_var in enumerate(self.state_vars):
|
|
241
|
+
state_deriv = sp.Derivative(state_var, "t") # symbolic derivative dx/dt
|
|
242
|
+
f[i] = self.solution[state_deriv]
|
|
243
|
+
|
|
244
|
+
h: sp.Matrix = sp.zeros(n_outputs, 1) # efforts then flows
|
|
245
|
+
for i, bond in enumerate(self.bonds):
|
|
246
|
+
h[i] = self.solution[bond.effort]
|
|
247
|
+
h[i + n_outputs // 2] = self.solution[bond.flow]
|
|
248
|
+
|
|
249
|
+
A = f.jacobian(self.state_vars)
|
|
250
|
+
B = f.jacobian(self.inputs)
|
|
251
|
+
|
|
252
|
+
C = h.jacobian(self.state_vars)
|
|
253
|
+
D = h.jacobian(self.inputs)
|
|
254
|
+
# alternatively could use sp.linear_eq_to_matrix(...)
|
|
255
|
+
|
|
256
|
+
return A, B, C, D, sp.Matrix(self.state_vars), n_states, n_inputs, n_outputs
|
|
257
|
+
|
|
258
|
+
def plot(self, layout: Callable[[nx.Graph, ...], dict] = nx.spectral_layout, **kwargs) -> tuple[plt.Figure, plt.Axes]:
|
|
259
|
+
"""Plots the bond graph as a `networkx` graph.
|
|
260
|
+
|
|
261
|
+
Parameters
|
|
262
|
+
----------
|
|
263
|
+
layout : Callable[[nx.Graph, ...], dict], optional
|
|
264
|
+
`networkx` layout function for plotting the graph, by default `nx.spectral_layout`
|
|
265
|
+
This is only called if the graph is not a directed acyclic graph (DAG).
|
|
266
|
+
If the graph is a DAG, a `multipartite_layout` is used instead.
|
|
267
|
+
|
|
268
|
+
Returns
|
|
269
|
+
-------
|
|
270
|
+
tuple[plt.Figure, plt.Axes]
|
|
271
|
+
Matplotlib Figure and axes objects for the plot.
|
|
272
|
+
Raises
|
|
273
|
+
------
|
|
274
|
+
ValueError
|
|
275
|
+
If an edge has no valid causality assigned.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
G = nx.DiGraph()
|
|
279
|
+
|
|
280
|
+
for elem in self.elements:
|
|
281
|
+
G.add_node(elem.name, label=elem.name)
|
|
282
|
+
|
|
283
|
+
for bond in self.bonds:
|
|
284
|
+
G.add_edge(
|
|
285
|
+
bond.from_element.name,
|
|
286
|
+
bond.to_element.name,
|
|
287
|
+
label=bond.num,
|
|
288
|
+
causality=bond.causality,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# https://networkx.org/documentation/stable/auto_examples/graph/plot_dag_layout.html
|
|
292
|
+
if nx.is_directed_acyclic_graph(G):
|
|
293
|
+
for layer, nodes in enumerate(nx.topological_generations(G)):
|
|
294
|
+
# `multipartite_layout` expects the layer as a node attribute, so add the
|
|
295
|
+
# numeric layer value as a node attribute
|
|
296
|
+
for node in nodes:
|
|
297
|
+
G.nodes[node]["layer"] = layer
|
|
298
|
+
|
|
299
|
+
pos = nx.multipartite_layout(G, subset_key="layer")
|
|
300
|
+
else:
|
|
301
|
+
pos = layout(G, **kwargs)
|
|
302
|
+
|
|
303
|
+
fig, ax = plt.subplots(figsize=(10, 8))
|
|
304
|
+
ax.set_axis_off()
|
|
305
|
+
nx.draw_networkx(
|
|
306
|
+
G,
|
|
307
|
+
pos,
|
|
308
|
+
ax=ax,
|
|
309
|
+
with_labels=True,
|
|
310
|
+
node_size=2000,
|
|
311
|
+
node_color="lightblue",
|
|
312
|
+
font_size=10,
|
|
313
|
+
font_color="black",
|
|
314
|
+
arrows=True,
|
|
315
|
+
)
|
|
316
|
+
nx.draw_networkx_edge_labels(
|
|
317
|
+
G,
|
|
318
|
+
pos,
|
|
319
|
+
edge_labels=nx.get_edge_attributes(G, "label"),
|
|
320
|
+
font_size=8,
|
|
321
|
+
ax=ax,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Function to add a perpendicular line
|
|
325
|
+
def draw_causal_stroke(ax, p1, p2, at="head", length=20, node_size=2000, padding=2):
|
|
326
|
+
"""Draw a causal stroke perpendicular to the edge (p1 -> p2).
|
|
327
|
+
|
|
328
|
+
Parameters
|
|
329
|
+
----------
|
|
330
|
+
ax : matplotlib.axes.Axes
|
|
331
|
+
The axes to draw on.
|
|
332
|
+
p1 : tuple[float, float]
|
|
333
|
+
The (x, y) coordinates of the first node.
|
|
334
|
+
p2 : tuple[float, float]
|
|
335
|
+
The (x, y) coordinates of the second node.
|
|
336
|
+
at : str, optional
|
|
337
|
+
Where to draw the stroke, by default "head"
|
|
338
|
+
length : int, optional
|
|
339
|
+
The length of the stroke, by default 20
|
|
340
|
+
node_size : int, optional
|
|
341
|
+
The size of the nodes, by default 2000
|
|
342
|
+
padding : int, optional
|
|
343
|
+
The padding between the node and the stroke, by default 2
|
|
344
|
+
|
|
345
|
+
Raises
|
|
346
|
+
------
|
|
347
|
+
ValueError
|
|
348
|
+
If the 'at' parameter is not "head" or "tail".
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
# --- Convert node size (points^2) to radius in pixels ---
|
|
352
|
+
radius_points = np.sqrt(node_size / np.pi)
|
|
353
|
+
radius_pixels = radius_points * ax.figure.dpi / 72.0 # 1 point = 1/72 inch
|
|
354
|
+
offset = radius_pixels + padding # + padding (in px) so stroke sits outside node
|
|
355
|
+
|
|
356
|
+
# Transform points to display (pixel) coordinates
|
|
357
|
+
p1_disp = ax.transData.transform(p1)
|
|
358
|
+
p2_disp = ax.transData.transform(p2)
|
|
359
|
+
|
|
360
|
+
# Edge vector in display coords
|
|
361
|
+
vec = p2_disp - p1_disp
|
|
362
|
+
norm = np.linalg.norm(vec)
|
|
363
|
+
if norm == 0:
|
|
364
|
+
return
|
|
365
|
+
uvec = vec / norm
|
|
366
|
+
|
|
367
|
+
# Perpendicular in display coords
|
|
368
|
+
perp = np.array([-uvec[1], uvec[0]])
|
|
369
|
+
|
|
370
|
+
# Base point (head or tail), offset in pixels
|
|
371
|
+
if at == "head":
|
|
372
|
+
base_disp = p2_disp - uvec * offset
|
|
373
|
+
elif at == "tail":
|
|
374
|
+
base_disp = p1_disp + uvec * offset
|
|
375
|
+
else:
|
|
376
|
+
raise ValueError(f"Invalid value for 'at': {at}")
|
|
377
|
+
|
|
378
|
+
# Stroke endpoints in display coords
|
|
379
|
+
p_start_disp = base_disp - perp * (length / 2)
|
|
380
|
+
p_end_disp = base_disp + perp * (length / 2)
|
|
381
|
+
|
|
382
|
+
# Transform back to data coords for plotting
|
|
383
|
+
p_start = ax.transData.inverted().transform(p_start_disp)
|
|
384
|
+
p_end = ax.transData.inverted().transform(p_end_disp)
|
|
385
|
+
|
|
386
|
+
ax.plot([p_start[0], p_end[0]], [p_start[1], p_end[1]], color="k", lw=1.0)
|
|
387
|
+
|
|
388
|
+
# Add perpendicular lines to edges
|
|
389
|
+
for u, v, data in G.edges(data=True):
|
|
390
|
+
causality = data.get("causality", None)
|
|
391
|
+
|
|
392
|
+
if causality is Causality.EFFORT_OUT:
|
|
393
|
+
draw_causal_stroke(ax, pos[u], pos[v], at="head", padding=-2)
|
|
394
|
+
elif causality is Causality.FLOW_OUT:
|
|
395
|
+
draw_causal_stroke(ax, pos[u], pos[v], at="tail", padding=-2)
|
|
396
|
+
else:
|
|
397
|
+
raise ValueError(f"Edge {u}->{v} has no valid causality: {causality} --> this should never happen!")
|
|
398
|
+
|
|
399
|
+
return fig, ax
|
pyBondGraph/core.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
import sympy as sp
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Causality(Enum):
|
|
8
|
+
"""Enumeration for bond causality types.
|
|
9
|
+
This definition is always from the perspective of the `from_element`.
|
|
10
|
+
Therefore a `Bond(OneJunction(...), Inductor(...), "effort_out")` means that the
|
|
11
|
+
`OneJunction` imposes effort on the `Inductor`, meaning it has an equivalent `effort_in` causality.
|
|
12
|
+
Likewise a `Bond(OneJunction(...), Capacitor(...), "flow_out")` means that the `OneJunction` imposes
|
|
13
|
+
flow on the `Capacitor`, meaning it has an equivalent `flow_in`/`effort_out` causality.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
EFFORT_OUT = "effort_out"
|
|
17
|
+
FLOW_OUT = "flow_out"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StatefulElement(ABC):
|
|
21
|
+
"""Base class for all stateful elements (capacitor, inductor) in the bond graph.
|
|
22
|
+
Requires implementation of a `state_var` property that returns the symbolic state variable associated with the element.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def state_var(self) -> sp.Symbol:
|
|
28
|
+
"""sp.Symbol: Returns the symbolic state variable associated with the stateful element."""
|
|
29
|
+
raise NotImplementedError("Subclasses should implement this method")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Node(ABC):
|
|
33
|
+
"""Base class for all nodes in the bond graph."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, name: str):
|
|
36
|
+
"""Base class for all nodes in the bond graph.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
name : str
|
|
41
|
+
The name of the node. Will be shown on the bond graph plot.
|
|
42
|
+
"""
|
|
43
|
+
self.name = name
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"{self.__class__.__name__}({self.name})"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Bond:
|
|
50
|
+
"""Represents a bond between two elements in the bond graph."""
|
|
51
|
+
|
|
52
|
+
counter = 0
|
|
53
|
+
|
|
54
|
+
def __init__(self, from_element: Node, to_element: Node, causality: str | Causality):
|
|
55
|
+
"""Create a bond between two elements with specified causality.
|
|
56
|
+
The positive direction of this power bond is from `from_element` to `to_element`.
|
|
57
|
+
Efforts and flows are represented by symbolic `sympy.Symbol`s that are strictly real-valued.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
from_element : Node
|
|
62
|
+
The element where the bond originates.
|
|
63
|
+
to_element : Node
|
|
64
|
+
The element where the bond terminates.
|
|
65
|
+
causality : str | Causality
|
|
66
|
+
The causality of the bond, either `effort_out` or `flow_out`.
|
|
67
|
+
If a string is provided, it is converted to the corresponding `Causality` enum.
|
|
68
|
+
This definition is always from the perspective of the `from_element`.
|
|
69
|
+
Therefore a `Bond(OneJunction(...), Inductor(...), "effort_out")` means that the
|
|
70
|
+
`OneJunction` imposes effort on the `Inductor`, meaning it has an equivalent `effort_in` causality.
|
|
71
|
+
Likewise a `Bond(OneJunction(...), Capacitor(...), "flow_out")` means that the `OneJunction` imposes
|
|
72
|
+
flow on the `Capacitor`, meaning it has an equivalent `flow_in`/`effort_out` causality.
|
|
73
|
+
|
|
74
|
+
Raises
|
|
75
|
+
------
|
|
76
|
+
ValueError
|
|
77
|
+
If the causality is not 'effort_out' or 'flow_out'.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
self.from_element = from_element
|
|
81
|
+
self.to_element = to_element
|
|
82
|
+
|
|
83
|
+
if isinstance(causality, str):
|
|
84
|
+
causality = Causality(causality.lower()) # Convert string to Causality enum, case insensitive
|
|
85
|
+
# --> automatically raises ValueError if string is not valid
|
|
86
|
+
|
|
87
|
+
self.causality: Causality = causality
|
|
88
|
+
|
|
89
|
+
self.num = Bond.counter
|
|
90
|
+
Bond.counter += 1
|
|
91
|
+
|
|
92
|
+
self.effort = sp.Symbol(f"e_{self.num}", real=True)
|
|
93
|
+
self.flow = sp.Symbol(f"f_{self.num}", real=True)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def elements(self) -> tuple[Node, Node]:
|
|
97
|
+
"""tuple[Node, Node]: The two elements connected by the bond. First element is `from_element`, second is `to_element`."""
|
|
98
|
+
return (self.from_element, self.to_element)
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f"Bond(from={self.from_element}, to={self.to_element}, causality={self.causality})"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Junction(Node, ABC):
|
|
105
|
+
"""Base class for all junctions in the bond graph."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, name: str):
|
|
108
|
+
"""Base class for all junctions in the bond graph.
|
|
109
|
+
Stores a list of associated bonds and a reference to the "strong" bond.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
name : str
|
|
114
|
+
The name of the junction. Will be shown on the bond graph plot.
|
|
115
|
+
"""
|
|
116
|
+
super().__init__(name)
|
|
117
|
+
self.bonds: list[Bond] = []
|
|
118
|
+
self.strong_bond = None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def equations(self) -> list[sp.Expr]:
|
|
123
|
+
"""list[sp.Expr]: Returns the symbolic equations defining the behavior of the junction."""
|
|
124
|
+
raise NotImplementedError("Subclasses should implement this method")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ElementOnePort(Node, ABC):
|
|
128
|
+
"""Base class for all one-port elements in the bond graph.
|
|
129
|
+
Requires implementation of an `equations` property that returns the list of symbolic equations defining the element's behavior.
|
|
130
|
+
This can also be just one equation.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self, name: str, value: str):
|
|
134
|
+
"""ABC for a one-port element. Acts as base class for all one-port elements like Inductor, Capacitor, Resistor, SourceEffort, SourceFlow.
|
|
135
|
+
Stores an associated bond and a symbolic value (real and positive) for its defining characteristics e.g. resistance, capacitance, etc.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
name : str
|
|
140
|
+
The name of the element. Will be shown on the bond graph plot.
|
|
141
|
+
value : str
|
|
142
|
+
The name of the value associated with the element, is internally used for creating a `sympy.Symbol`.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
super().__init__(name)
|
|
146
|
+
self.value = sp.Symbol(value, real=True, positive=True) # Ensure value is a positive real number
|
|
147
|
+
self.bond: Bond = None # bond that connects this element to a bond graph
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
@abstractmethod
|
|
151
|
+
def equations(self) -> list[sp.Expr]:
|
|
152
|
+
"""list[sp.Expr]: Returns the symbolic equations defining the behavior of the one-port element."""
|
|
153
|
+
raise NotImplementedError("Subclasses should implement this method")
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
return f"{self.__class__.__name__}(name = {self.name}, value = {self.value})"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ElementTwoPort(Node, ABC):
|
|
160
|
+
"""Base class for all two-port elements in the bond graph.
|
|
161
|
+
Requires implementation of an `equations` property that returns the list of symbolic equations defining the element's behavior.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, name: str, value: str):
|
|
165
|
+
"""ABC for a two-port element. Acts as base class for all two-port elements like Transformer, Gyrator.
|
|
166
|
+
Stores two associated bonds and a symbolic value (real and positive) for its defining characteristics e.g. conversion factor, ratio, etc.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
name : str
|
|
171
|
+
The name of the element. Will be shown on the bond graph plot.
|
|
172
|
+
value : str
|
|
173
|
+
The name of the value associated with the element, is internally used for creating a `sympy.Symbol`.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
super().__init__(name)
|
|
177
|
+
self.value = sp.Symbol(value, real=True, positive=True)
|
|
178
|
+
self.bond1: Bond = None # ElementOther --(bond1)--> ElementTwoPort
|
|
179
|
+
self.bond2: Bond = None # ElementTwoPort --(bond2)--> ElementOther
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
@abstractmethod
|
|
183
|
+
def equations(self) -> list[sp.Expr]:
|
|
184
|
+
"""list[sp.Expr]: Returns the symbolic equations defining the behavior of the two-port element."""
|
|
185
|
+
raise NotImplementedError("Subclasses should implement this method")
|
|
186
|
+
|
|
187
|
+
def __repr__(self) -> str:
|
|
188
|
+
return f"{self.__class__.__name__}(name = {self.name}, value = {self.value})"
|
pyBondGraph/elements.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
from .core import Causality, StatefulElement, ElementOnePort, ElementTwoPort, Junction
|
|
2
|
+
|
|
3
|
+
import sympy as sp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SourceEffort(ElementOnePort):
|
|
7
|
+
"""Represents a source of effort in the bond graph.
|
|
8
|
+
A source of effort provides a constant effort to its port, the associated flow follows automatically."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, name: str, value: str):
|
|
11
|
+
"""Create a source of effort in the bond graph.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
name : str
|
|
16
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
17
|
+
value : str
|
|
18
|
+
The name of the element value, is internally used for creating a `sympy.Symbol`.
|
|
19
|
+
"""
|
|
20
|
+
super().__init__(name, value)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def equations(self) -> list[sp.Expr]:
|
|
24
|
+
# Source --> constant effort
|
|
25
|
+
return [self.bond.effort - self.value]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SourceFlow(ElementOnePort):
|
|
29
|
+
"""Represents a source of flow in the bond graph.
|
|
30
|
+
A source of flow provides a constant flow to its port, the associated effort follows automatically.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, name: str, value: str):
|
|
34
|
+
"""Create a source of flow in the bond graph.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
name : str
|
|
39
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
40
|
+
value : str
|
|
41
|
+
The name of the element value, is internally used for creating a `sympy.Symbol`.
|
|
42
|
+
"""
|
|
43
|
+
super().__init__(name, value)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def equations(self) -> list[sp.Expr]:
|
|
47
|
+
# Source --> constant flow
|
|
48
|
+
return [self.bond.flow - self.value]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Capacitor(ElementOnePort, StatefulElement):
|
|
52
|
+
"""Represents a linear compliance/capacitance element in the bond graph.
|
|
53
|
+
A capacitance relates the effort of its port with the integral of its flow by a constant capacitance value.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, name: str, value: str):
|
|
57
|
+
"""Create a linear compliance/capacitance element in the bond graph.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
name : str
|
|
62
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
63
|
+
value : str
|
|
64
|
+
The name of the element value, is internally used for creating a `sympy.Symbol`.
|
|
65
|
+
"""
|
|
66
|
+
super().__init__(name, value)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def state_var(self) -> sp.Symbol:
|
|
70
|
+
"""Returns the symbolic state variable (generalized displacement) associated with the compliance/capacitance.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
sp.Symbol
|
|
75
|
+
The symbolic state variable representing the generalized displacement (q) of the compliance/capacitance.
|
|
76
|
+
"""
|
|
77
|
+
return sp.Symbol(f"q_{self.name}", real=True)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def equations(self) -> list[sp.Expr]:
|
|
81
|
+
"""Returns the symbolic equations defining the behavior of the linear compliance/capacitance.
|
|
82
|
+
Formulated in the derivative of the internal state_var, stemming from integral causality.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
list[sp.Expr]
|
|
87
|
+
A list of symbolic equations representing the behavior of the compliance/capacitance.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# Dynamic equation: f = dq/dt, e = q / C
|
|
91
|
+
return [
|
|
92
|
+
self.bond.flow - sp.Derivative(self.state_var, "t"),
|
|
93
|
+
self.bond.effort - self.state_var / self.value,
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
type Compliance = Capacitor # Alias for Compliance element
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Inductor(ElementOnePort, StatefulElement):
|
|
101
|
+
"""Represents a linear inertia/inductance element in the bond graph.
|
|
102
|
+
An inductor relates the flow of its port with the integral of its effort by a constant inductance value.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, name: str, value: str):
|
|
106
|
+
"""Create a linear inertia/inductance element in the bond graph.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
name : str
|
|
111
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
112
|
+
value : str
|
|
113
|
+
The name of the element value, is internally used for creating a `sympy.Symbol`.
|
|
114
|
+
"""
|
|
115
|
+
super().__init__(name, value)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def state_var(self) -> sp.Symbol:
|
|
119
|
+
"""Returns the symbolic state variable (generalized momentum) associated with the inertia/inductance.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
sp.Symbol
|
|
124
|
+
The symbolic state variable representing the generalized momentum (p) of the inertia/inductance.
|
|
125
|
+
"""
|
|
126
|
+
return sp.Symbol(f"p_{self.name}", real=True)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def equations(self) -> list[sp.Expr]:
|
|
130
|
+
"""Returns the symbolic equations defining the behavior of the linear inertia/inductance.
|
|
131
|
+
Formulated in the derivative of the internal state_var, stemming from integral causality.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
list[sp.Expr]
|
|
136
|
+
A list of symbolic equations representing the behavior of the inertia/inductance.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
# Dynamic equation: e = dp/dt, f = p / L
|
|
140
|
+
return [
|
|
141
|
+
self.bond.effort - sp.Derivative(self.state_var, "t"),
|
|
142
|
+
self.bond.flow - self.state_var / self.value,
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
type Inertance = Inductor # Alias for Inertia element
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Resistor(ElementOnePort):
|
|
150
|
+
"""Represents a linear resistance/damper element in the bond graph.
|
|
151
|
+
A resistor relates the effort and flow of its port by a constant resistance value.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, name: str, value: str):
|
|
155
|
+
"""Create a linear resistance element.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
name : str
|
|
160
|
+
The name of the resistance. Forwarded to the `Node` base class.
|
|
161
|
+
value : str
|
|
162
|
+
The name of the resistance value, is internally used for creating a `sympy.Symbol`.
|
|
163
|
+
"""
|
|
164
|
+
super().__init__(name, value)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def equations(self) -> list[sp.Expr]:
|
|
168
|
+
"""Returns the symbolic equations defining the behavior of the linear resistance.
|
|
169
|
+
The equation is formulated based on the causality of the associated bond.
|
|
170
|
+
This makes resolving causality issues easier, as there is no preferred one for resistors.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
list[sp.Expr]
|
|
175
|
+
A list of symbolic equations representing the behavior of the resistance.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
# Resistance: e = R * f
|
|
179
|
+
if self.bond.causality == Causality.EFFORT_OUT:
|
|
180
|
+
return [self.bond.flow - 1 / self.value * self.bond.effort]
|
|
181
|
+
elif self.bond.causality == Causality.FLOW_OUT:
|
|
182
|
+
return [self.bond.effort - self.value * self.bond.flow]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
type Resistance = Resistor # Alias for Resistance element
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Transformer(ElementTwoPort):
|
|
189
|
+
"""Represents a transformer element in the bond graph.
|
|
190
|
+
A transformer relates the efforts of its two ports and the flows of its two ports by a constant ratio.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, name: str, value: str):
|
|
194
|
+
"""Create a transformer element in the bond graph.
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
name : str
|
|
199
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
200
|
+
value : str
|
|
201
|
+
The name of the element value, is internally used for creating a `sympy.Symbol`.
|
|
202
|
+
"""
|
|
203
|
+
super().__init__(name, value)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def equations(self) -> list[sp.Expr]:
|
|
207
|
+
"""Returns the symbolic equations defining the behavior of the transformer.
|
|
208
|
+
The equations are formulated based on the causality of the associated bonds.
|
|
209
|
+
This makes resolving causality issues easier, as there is no preferred causality for transformers.
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
list[sp.Expr]
|
|
214
|
+
A list of symbolic equations representing the behavior of the transformer.
|
|
215
|
+
|
|
216
|
+
Raises
|
|
217
|
+
------
|
|
218
|
+
ValueError
|
|
219
|
+
If both bonds are not assigned or if they do not have the same causality.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
if self.bond1 is None or self.bond2 is None:
|
|
223
|
+
raise ValueError("Both bonds must be assigned to the transformer element.")
|
|
224
|
+
|
|
225
|
+
if self.bond1.causality != self.bond2.causality:
|
|
226
|
+
raise ValueError("Both bonds must have the same causality for a transformer element.")
|
|
227
|
+
|
|
228
|
+
if self.bond1.causality == Causality.EFFORT_OUT:
|
|
229
|
+
return [
|
|
230
|
+
self.bond1.flow - 1 / self.value * self.bond2.flow,
|
|
231
|
+
self.bond2.effort - 1 / self.value * self.bond1.effort,
|
|
232
|
+
]
|
|
233
|
+
elif self.bond1.causality == Causality.FLOW_OUT:
|
|
234
|
+
return [
|
|
235
|
+
self.bond1.effort - self.value * self.bond2.effort,
|
|
236
|
+
self.bond2.flow - self.value * self.bond1.flow,
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Gyrator(ElementTwoPort):
|
|
241
|
+
"""Represents a gyrator element in the bond graph.
|
|
242
|
+
A gyrator relates the effort of one port with the flow of the other (and vice versa) by a constant ratio.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, name: str, value: str):
|
|
246
|
+
"""Create a gyrator element in the bond graph.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
name : str
|
|
251
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
252
|
+
value : str
|
|
253
|
+
The name of the element value, is internally used for creating a `sympy.Symbol`.
|
|
254
|
+
"""
|
|
255
|
+
super().__init__(name, value)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def equations(self) -> list[sp.Expr]:
|
|
259
|
+
"""Returns the symbolic equations defining the behavior of the gyrator.
|
|
260
|
+
The equations are formulated based on the causality of the associated bonds.
|
|
261
|
+
This makes resolving causality issues easier, as there is no preferred causality for gyrators.
|
|
262
|
+
|
|
263
|
+
Returns
|
|
264
|
+
-------
|
|
265
|
+
list[sp.Expr]
|
|
266
|
+
A list of symbolic equations representing the behavior of the gyrator.
|
|
267
|
+
|
|
268
|
+
Raises
|
|
269
|
+
------
|
|
270
|
+
ValueError
|
|
271
|
+
If both bonds are not assigned or if they have the same causality.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
if self.bond1 is None or self.bond2 is None:
|
|
275
|
+
raise ValueError("Both bonds must be assigned to the gyrator element.")
|
|
276
|
+
|
|
277
|
+
if self.bond1.causality == self.bond2.causality:
|
|
278
|
+
raise ValueError("Both bonds must have the different causality for a gyrator element.")
|
|
279
|
+
|
|
280
|
+
if self.bond1.causality == Causality.EFFORT_OUT:
|
|
281
|
+
return [
|
|
282
|
+
self.bond1.flow - 1 / self.value * self.bond2.effort,
|
|
283
|
+
self.bond2.flow - 1 / self.value * self.bond1.effort,
|
|
284
|
+
]
|
|
285
|
+
elif self.bond1.causality == Causality.FLOW_OUT:
|
|
286
|
+
return [
|
|
287
|
+
self.bond1.effort - self.value * self.bond2.flow,
|
|
288
|
+
self.bond2.effort - self.value * self.bond1.flow,
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class OneJunction(Junction):
|
|
293
|
+
"""Represents a one-junction element in the bond graph.
|
|
294
|
+
A one-junction enforces equal flow (current, velocity, etc.) on all connected bonds and the sum of efforts to be zero .
|
|
295
|
+
The sign convention is that efforts of bonds going into the junction are positive, efforts of bonds going out of the junction are negative.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
def __init__(self, name: str):
|
|
299
|
+
"""Create a one-junction element in the bond graph.
|
|
300
|
+
|
|
301
|
+
Parameters
|
|
302
|
+
----------
|
|
303
|
+
name : str
|
|
304
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
305
|
+
"""
|
|
306
|
+
super().__init__(name)
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def equations(self) -> list[sp.Expr]:
|
|
310
|
+
"""Returns the symbolic equations defining the behavior of the one-junction.
|
|
311
|
+
|
|
312
|
+
Returns
|
|
313
|
+
-------
|
|
314
|
+
list[sp.Expr]
|
|
315
|
+
A list of symbolic equations representing the behavior of the one-junction.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
effort_eq = 0
|
|
319
|
+
for b in self.bonds:
|
|
320
|
+
dir = +1 if b.to_element == self else -1
|
|
321
|
+
effort_eq += dir * b.effort
|
|
322
|
+
|
|
323
|
+
flow_eq = [self.bonds[0].flow - b.flow for b in self.bonds[1:]]
|
|
324
|
+
return [effort_eq, *flow_eq]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ZeroJunction(Junction):
|
|
328
|
+
"""Represents a zero-junction element in the bond graph.
|
|
329
|
+
A zero-junction enforces equal effort (voltage, force, etc.) on all connected bonds and the sum of flows to be zero .
|
|
330
|
+
The sign convention is that flows of bonds going into the junction are positive, flows of bonds going out of the junction are negative.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
def __init__(self, name: str):
|
|
334
|
+
"""Create a zero-junction element in the bond graph.
|
|
335
|
+
|
|
336
|
+
Parameters
|
|
337
|
+
----------
|
|
338
|
+
name : str
|
|
339
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
340
|
+
"""
|
|
341
|
+
super().__init__(name)
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def equations(self) -> list[sp.Expr]:
|
|
345
|
+
"""Returns the symbolic equations defining the behavior of the zero-junction.
|
|
346
|
+
|
|
347
|
+
Returns
|
|
348
|
+
-------
|
|
349
|
+
list[sp.Expr]
|
|
350
|
+
A list of symbolic equations representing the behavior of the zero-junction.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
flow_eq = 0
|
|
354
|
+
for b in self.bonds:
|
|
355
|
+
dir = +1 if b.to_element == self else -1
|
|
356
|
+
flow_eq += dir * b.flow
|
|
357
|
+
|
|
358
|
+
effort_eq = [self.bonds[0].effort - b.effort for b in self.bonds[1:]]
|
|
359
|
+
return [flow_eq, *effort_eq]
|
pyBondGraph/sensors.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from .core import StatefulElement, ElementOnePort
|
|
2
|
+
|
|
3
|
+
import sympy as sp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IntegratedEffortSensor(ElementOnePort, StatefulElement):
|
|
7
|
+
"""Represents a sensor that integrates effort to its internal state variable."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, name: str):
|
|
10
|
+
"""Create a sensor that integrates effort to its internal state variable.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
name : str
|
|
15
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
16
|
+
"""
|
|
17
|
+
super().__init__(name, "")
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def state_var(self) -> sp.Symbol:
|
|
21
|
+
"""Returns the symbolic state variable (integrated effort) associated with the sensor.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
sp.Symbol
|
|
26
|
+
The symbolic state variable representing the integrated effort.
|
|
27
|
+
"""
|
|
28
|
+
return sp.Symbol(f"e_int_{self.name}", real=True)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def equations(self) -> list[sp.Expr]:
|
|
32
|
+
"""Returns the symbolic equations defining the behavior of the sensor that integrates effort.
|
|
33
|
+
Formulated in the derivative of the internal state_var, stemming from integral causality.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
list[sp.Expr]
|
|
38
|
+
A list of symbolic equations representing the behavior of the sensor.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# Dynamic equation: e = d(state_var)/dt, f = 0
|
|
42
|
+
return [
|
|
43
|
+
self.bond.effort - sp.Derivative(self.state_var, "t"),
|
|
44
|
+
self.bond.flow
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
class IntegratedFlowSensor(ElementOnePort, StatefulElement):
|
|
48
|
+
"""Represents a sensor that integrates flow to its internal state variable."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, name: str):
|
|
51
|
+
"""Create a sensor that integrates effort to its internal state variable.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
name : str
|
|
56
|
+
The name of the element. Forwarded to the `Node` base class.
|
|
57
|
+
"""
|
|
58
|
+
super().__init__(name, "")
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def state_var(self) -> sp.Symbol:
|
|
62
|
+
"""Returns the symbolic state variable (integrated flow) associated with the sensor.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
sp.Symbol
|
|
67
|
+
The symbolic state variable representing the integrated flow.
|
|
68
|
+
"""
|
|
69
|
+
return sp.Symbol(f"f_int_{self.name}", real=True)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def equations(self) -> list[sp.Expr]:
|
|
73
|
+
"""Returns the symbolic equations defining the behavior of the sensor that integrates flow.
|
|
74
|
+
Formulated in the derivative of the internal state_var, stemming from integral causality.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
list[sp.Expr]
|
|
79
|
+
A list of symbolic equations representing the behavior of the sensor.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
# Dynamic equation: f = d(state_var)/dt, e = 0
|
|
83
|
+
return [
|
|
84
|
+
self.bond.flow - sp.Derivative(self.state_var, "t"),
|
|
85
|
+
self.bond.effort
|
|
86
|
+
]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyBondGraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Modelling tool for linear bond graph systems in Python
|
|
5
|
+
Author-email: Matthias Panny <matthias.panny@mci.edu>
|
|
6
|
+
License-Expression: CC-BY-NC-SA-4.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/MrP123/pyBondGraph
|
|
8
|
+
Project-URL: Issues, https://github.com/MrP123/pyBondGraph/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Requires-Python: >=3.13
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: sympy>=1.14.0
|
|
18
|
+
Requires-Dist: networkx>=3.5
|
|
19
|
+
Requires-Dist: numpy>=2.3.0
|
|
20
|
+
Requires-Dist: matplotlib>=3.10.3
|
|
21
|
+
Requires-Dist: control>=0.10.2
|
|
22
|
+
Provides-Extra: streamlit
|
|
23
|
+
Requires-Dist: streamlit>=1.47.0; extra == "streamlit"
|
|
24
|
+
Requires-Dist: streamlit-flow-component>=1.6.1; extra == "streamlit"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# pyBondGraph
|
|
28
|
+
**pyBondGraph** is a Python library for **modeling and analyzing linear bond graph systems** using symbolic computation.
|
|
29
|
+
|
|
30
|
+
The library allows users to construct bond graph models programmatically, automatically derive the governing equations, and analyze the resulting dynamic systems using tools from control theory.
|
|
31
|
+
|
|
32
|
+
Bond graphs provide a **domain-independent modeling framework** for physical systems. Using a unified representation of power exchange, the same modeling approach can be used for electrical, mechanical, hydraulic, and multi-domain systems.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# Features
|
|
37
|
+
|
|
38
|
+
* Programmatic construction of **bond graph models**
|
|
39
|
+
* Automatic **symbolic equation derivation** using SymPy
|
|
40
|
+
* Conversion of models to **state-space systems**
|
|
41
|
+
* Example models for electrical and mechanical systems
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
# Installation
|
|
46
|
+
|
|
47
|
+
## Install directly from GitHub
|
|
48
|
+
The easiest way to install the library is directly via pip:
|
|
49
|
+
```bash
|
|
50
|
+
pip install git+https://github.com/MrP123/pyBondGraph.git
|
|
51
|
+
```
|
|
52
|
+
This installs the latest version of the package from the repository.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Development installation
|
|
57
|
+
To work with the source code:
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/MrP123/pyBondGraph.git
|
|
60
|
+
cd pyBondGraph
|
|
61
|
+
pip install -e .
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
# Dependencies
|
|
67
|
+
|
|
68
|
+
The main dependencies are:
|
|
69
|
+
|
|
70
|
+
* `sympy`
|
|
71
|
+
* `numpy`
|
|
72
|
+
* `networkx`
|
|
73
|
+
* `matplotlib`
|
|
74
|
+
* `control` only needed for the examples
|
|
75
|
+
|
|
76
|
+
Optional dependencies are used for experimental visualization tools.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
# Basic Usage
|
|
81
|
+
A bond graph model is constructed by creating elements and connecting them via bonds.
|
|
82
|
+
|
|
83
|
+
Simple RC-Filter circuit:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from pyBondGraph import BondGraph, SourceEffort, Resistor, Capacitor, OneJunction, Bond, Causality
|
|
87
|
+
|
|
88
|
+
bg = BondGraph()
|
|
89
|
+
|
|
90
|
+
# create elements
|
|
91
|
+
voltage_source = SourceEffort("U", "u_in")
|
|
92
|
+
resistor = Resistor("R", "R")
|
|
93
|
+
capacitor = Capacitor("C", "C")
|
|
94
|
+
series_junction = OneJunction("J1")
|
|
95
|
+
|
|
96
|
+
# connect elements
|
|
97
|
+
# causalities need to be assigned manually
|
|
98
|
+
bg.add_bond(Bond(voltage_source, series_junction, Causality.EFFORT_OUT))
|
|
99
|
+
bg.add_bond(Bond(series_junction, resistor, Causality.EFFORT_OUT))
|
|
100
|
+
bg.add_bond(Bond(series_junction, capacitor, Causality.FLOW_OUT))
|
|
101
|
+
|
|
102
|
+
# plot the resulting BondGraph
|
|
103
|
+
bg.plot()
|
|
104
|
+
|
|
105
|
+
# derive system equations in linear state space form
|
|
106
|
+
A, B, C, D, x, n_states, n_inputs, n_outputs = bond_graph.get_state_space()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The library automatically derives the **symbolic system equations** describing the dynamics of the model.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Core Concepts
|
|
115
|
+
Bond graphs represent **power exchange between system components**, where power is the product of **effort** and **flow** associated with the following components:
|
|
116
|
+
|
|
117
|
+
## Elements
|
|
118
|
+
| Element | Meaning |
|
|
119
|
+
|---------|------------------------------------------|
|
|
120
|
+
| R | Dissipation |
|
|
121
|
+
| C | Energy storage (compliance, capacitance) |
|
|
122
|
+
| I | Energy storage (inertia, inductance) |
|
|
123
|
+
| Se | Effort source |
|
|
124
|
+
| Sf | Flow source |
|
|
125
|
+
|
|
126
|
+
## Junctions
|
|
127
|
+
| Junction | Meaning |
|
|
128
|
+
|----------|---------------|
|
|
129
|
+
| 0 | Common effort |
|
|
130
|
+
| 1 | Common flow |
|
|
131
|
+
|
|
132
|
+
## Sensors
|
|
133
|
+
| Sensor | Meaning |
|
|
134
|
+
|------------------------|---------------------------------------------|
|
|
135
|
+
| IntegratedEffortSensor | Measures integral of the effort at its bond |
|
|
136
|
+
| IntegratedFlowSensor | Measures integral of the flow at its bond |
|
|
137
|
+
|
|
138
|
+
In mechanical bond graph models:
|
|
139
|
+
* **flow** corresponds to **velocity**
|
|
140
|
+
* **effort** corresponds to **force**
|
|
141
|
+
|
|
142
|
+
An **integrated flow sensor** can therefore be used to compute **position**:
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
# Example Systems
|
|
147
|
+
The repository contains example models illustrating typical applications of bond graphs.
|
|
148
|
+
|
|
149
|
+
### RLC Circuit
|
|
150
|
+
Demonstrates modeling of an electrical circuit using bond graph elements.
|
|
151
|
+
|
|
152
|
+
### DC Motor
|
|
153
|
+
A multi-domain electromechanical system coupling electrical and mechanical dynamics.
|
|
154
|
+
|
|
155
|
+
### Transformer
|
|
156
|
+
Example of energy transformation between two ports.
|
|
157
|
+
|
|
158
|
+
### Two DOF Mass–Spring–Damper System
|
|
159
|
+
Classical mass-spring-damper system with two degrees of freedom.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
# Typical Applications
|
|
164
|
+
Bond graph modeling is particularly useful for:
|
|
165
|
+
|
|
166
|
+
* electromechanical systems
|
|
167
|
+
* robotics and mechatronics
|
|
168
|
+
* multi-domain energy systems
|
|
169
|
+
* control system modeling
|
|
170
|
+
* teaching system dynamics
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyBondGraph/__init__.py,sha256=-C74b1kF3iyVfMe9RwvsA0DzznMe1UQa8jopn-1cSAY,717
|
|
2
|
+
pyBondGraph/bondgraph.py,sha256=5MqOFeNWEDSM6E5AD7TSfblAWF59IWeVwu3qOxAaP04,16656
|
|
3
|
+
pyBondGraph/core.py,sha256=rDT82D6_1jUzrq1uJ3YLxHGUKfOKtr313vO2uaw-CiM,7851
|
|
4
|
+
pyBondGraph/elements.py,sha256=yQu88NRoDbnG8r8gPZ5NoN7ydA0Oud7fzW1AiWBx3S8,13170
|
|
5
|
+
pyBondGraph/sensors.py,sha256=p8HdCkClxslBuMsd46mQKn2uuufWGZLBh_iV5boZRZk,2891
|
|
6
|
+
pybondgraph-0.1.0.dist-info/licenses/LICENSE,sha256=gVYUJmavXr6Y0lO28X9WXKiwuEz0HaTCAJRIVkXuXb8,202
|
|
7
|
+
pybondgraph-0.1.0.dist-info/METADATA,sha256=WG1REhQcB4itvhGW-51VsXmm9dtVxrl_fgn79fqBZXo,5459
|
|
8
|
+
pybondgraph-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
pybondgraph-0.1.0.dist-info/top_level.txt,sha256=N3BmEIcuMU1Cqbc6ayds0jzxyZwyOA1MAy5sCO4qZ2o,12
|
|
10
|
+
pybondgraph-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyBondGraph
|