pyBondGraph 0.1.0__tar.gz → 0.2.0__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.
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/PKG-INFO +7 -4
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/README.md +6 -3
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph/__init__.py +4 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph/bondgraph.py +170 -30
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph/core.py +65 -6
- pybondgraph-0.2.0/pyBondGraph/subbondgraph.py +315 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph.egg-info/PKG-INFO +7 -4
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph.egg-info/SOURCES.txt +1 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyproject.toml +1 -1
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/LICENSE +0 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph/elements.py +0 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph/sensors.py +0 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph.egg-info/dependency_links.txt +0 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph.egg-info/requires.txt +0 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/pyBondGraph.egg-info/top_level.txt +0 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/requirements.txt +0 -0
- {pybondgraph-0.1.0 → pybondgraph-0.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyBondGraph
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Modelling tool for linear bond graph systems in Python
|
|
5
5
|
Author-email: Matthias Panny <matthias.panny@mci.edu>
|
|
6
6
|
License-Expression: CC-BY-NC-SA-4.0
|
|
@@ -44,12 +44,15 @@ Bond graphs provide a **domain-independent modeling framework** for physical sys
|
|
|
44
44
|
|
|
45
45
|
# Installation
|
|
46
46
|
|
|
47
|
-
## Install
|
|
48
|
-
The easiest way to install the library is directly
|
|
47
|
+
## Install from PyPI or Github
|
|
48
|
+
The easiest way to install the library is via your preferred package manager (e.g. pip) directly from PyPI:
|
|
49
|
+
```bash
|
|
50
|
+
pip install pyBondGraph
|
|
51
|
+
```
|
|
52
|
+
Alternatively one can install the latest development version directly from the GitHub repository:
|
|
49
53
|
```bash
|
|
50
54
|
pip install git+https://github.com/MrP123/pyBondGraph.git
|
|
51
55
|
```
|
|
52
|
-
This installs the latest version of the package from the repository.
|
|
53
56
|
|
|
54
57
|
---
|
|
55
58
|
|
|
@@ -18,12 +18,15 @@ Bond graphs provide a **domain-independent modeling framework** for physical sys
|
|
|
18
18
|
|
|
19
19
|
# Installation
|
|
20
20
|
|
|
21
|
-
## Install
|
|
22
|
-
The easiest way to install the library is directly
|
|
21
|
+
## Install from PyPI or Github
|
|
22
|
+
The easiest way to install the library is via your preferred package manager (e.g. pip) directly from PyPI:
|
|
23
|
+
```bash
|
|
24
|
+
pip install pyBondGraph
|
|
25
|
+
```
|
|
26
|
+
Alternatively one can install the latest development version directly from the GitHub repository:
|
|
23
27
|
```bash
|
|
24
28
|
pip install git+https://github.com/MrP123/pyBondGraph.git
|
|
25
29
|
```
|
|
26
|
-
This installs the latest version of the package from the repository.
|
|
27
30
|
|
|
28
31
|
---
|
|
29
32
|
|
|
@@ -14,6 +14,8 @@ from .elements import (
|
|
|
14
14
|
Gyrator,
|
|
15
15
|
)
|
|
16
16
|
from .sensors import IntegratedEffortSensor, IntegratedFlowSensor
|
|
17
|
+
from .subbondgraph import SubBondGraph
|
|
18
|
+
from .core import Port # type alias: dict[str, Node]
|
|
17
19
|
|
|
18
20
|
from .bondgraph import BondGraph
|
|
19
21
|
|
|
@@ -35,4 +37,6 @@ __all__ = [
|
|
|
35
37
|
"BondGraph",
|
|
36
38
|
"IntegratedEffortSensor",
|
|
37
39
|
"IntegratedFlowSensor",
|
|
40
|
+
"Port",
|
|
41
|
+
"SubBondGraph",
|
|
38
42
|
]
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import sympy as sp
|
|
2
4
|
import networkx as nx
|
|
3
5
|
import numpy as np
|
|
6
|
+
|
|
7
|
+
from matplotlib.lines import Line2D
|
|
4
8
|
import matplotlib.pyplot as plt
|
|
5
9
|
|
|
6
10
|
from collections.abc import Callable
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
7
12
|
|
|
8
13
|
from .core import (
|
|
9
14
|
Causality,
|
|
@@ -16,19 +21,30 @@ from .core import (
|
|
|
16
21
|
)
|
|
17
22
|
from .elements import SourceEffort, SourceFlow, OneJunction, ZeroJunction
|
|
18
23
|
|
|
24
|
+
from .core import Port
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from .subbondgraph import SubBondGraph
|
|
28
|
+
|
|
19
29
|
type SolutionType = dict[sp.Expr, sp.Expr]
|
|
20
30
|
|
|
21
31
|
|
|
22
32
|
class BondGraph:
|
|
23
33
|
"""Represents a bond graph consisting of elements and the bonds that connect them."""
|
|
24
34
|
|
|
25
|
-
def __init__(self):
|
|
26
|
-
"""Initializes a new bond graph without any elements or bonds.
|
|
35
|
+
def __init__(self, name: str = ""):
|
|
36
|
+
"""Initializes a new bond graph without any elements or bonds.
|
|
27
37
|
|
|
28
|
-
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
name : str, optional
|
|
41
|
+
Name of the bond graph. Used as namespace prefix when merging sub-models.
|
|
42
|
+
"""
|
|
29
43
|
|
|
30
|
-
self.
|
|
44
|
+
self.name = name
|
|
45
|
+
self.elements: list[Node] = []
|
|
31
46
|
self.bonds: list[Bond] = []
|
|
47
|
+
self._bond_counter = 0 # Instance-scoped bond counter
|
|
32
48
|
|
|
33
49
|
self.state_vars: list[sp.Expr] = []
|
|
34
50
|
self.equations: list[sp.Expr] = []
|
|
@@ -56,6 +72,14 @@ class BondGraph:
|
|
|
56
72
|
if bond in self.bonds:
|
|
57
73
|
raise ValueError(f"Bond {bond} is already part of the bond graph.")
|
|
58
74
|
|
|
75
|
+
# Renumber the bond using the instance-scoped counter to avoid collisions
|
|
76
|
+
subs = bond.rename_symbols(new_num=self._bond_counter)
|
|
77
|
+
self._bond_counter += 1
|
|
78
|
+
|
|
79
|
+
# Propagate renamed symbols into any equations that already reference the old symbols
|
|
80
|
+
# (relevant when merging sub-models whose equations were built with different numbering)
|
|
81
|
+
self.equations = [eq.subs(subs) for eq in self.equations]
|
|
82
|
+
|
|
59
83
|
self.bonds.append(bond)
|
|
60
84
|
|
|
61
85
|
for element in bond.elements:
|
|
@@ -160,8 +184,6 @@ class BondGraph:
|
|
|
160
184
|
def get_solution_equations(self) -> SolutionType:
|
|
161
185
|
"""Returns the symbolic equations defining the solution of the bond graph.
|
|
162
186
|
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
187
|
|
|
166
188
|
Returns
|
|
167
189
|
-------
|
|
@@ -172,8 +194,6 @@ class BondGraph:
|
|
|
172
194
|
|
|
173
195
|
self.__handle_bonds()
|
|
174
196
|
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
197
|
state_derivatives = [sp.Derivative(var, "t") for var in self.state_vars]
|
|
178
198
|
self.solution = sp.solve(
|
|
179
199
|
self.equations,
|
|
@@ -255,6 +275,93 @@ class BondGraph:
|
|
|
255
275
|
|
|
256
276
|
return A, B, C, D, sp.Matrix(self.state_vars), n_states, n_inputs, n_outputs
|
|
257
277
|
|
|
278
|
+
def add_subbondgraph(self, sub_bondgraph: SubBondGraph, instance_name: str | None = None, is_prefix: bool = True) -> Port:
|
|
279
|
+
"""Instantiate a SubBondGraph into this bond graph.
|
|
280
|
+
|
|
281
|
+
Deep-copies all elements and bonds from the sub-model with
|
|
282
|
+
namespace prefixing, merges them into this graph, and returns
|
|
283
|
+
the instantiated ports for subsequent ``connect()`` calls.
|
|
284
|
+
|
|
285
|
+
Parameters
|
|
286
|
+
----------
|
|
287
|
+
sub_bondgraph : SubBondGraph
|
|
288
|
+
The sub-model to instantiate.
|
|
289
|
+
instance_name : str | None, optional
|
|
290
|
+
Override the namespace prefix. Defaults to the sub-model's name.
|
|
291
|
+
is_prefix : bool, optional
|
|
292
|
+
Whether to use the instance name as a prefix or suffix for the copied elements. Defaults to True.
|
|
293
|
+
|
|
294
|
+
Returns
|
|
295
|
+
-------
|
|
296
|
+
Port
|
|
297
|
+
Mapping of port names to new Node objects belonging
|
|
298
|
+
to this graph.
|
|
299
|
+
"""
|
|
300
|
+
return sub_bondgraph._instantiate(self, instance_name, is_prefix)
|
|
301
|
+
|
|
302
|
+
def connect(
|
|
303
|
+
self,
|
|
304
|
+
node_a: Node,
|
|
305
|
+
node_b: Node,
|
|
306
|
+
causality: Causality = Causality.EFFORT_OUT,
|
|
307
|
+
) -> Bond:
|
|
308
|
+
"""Connect two nodes by adding a bond between them.
|
|
309
|
+
|
|
310
|
+
Also used after :meth:`add_subbondgraph` to wire up the
|
|
311
|
+
returned port nodes — but any :class:`Node` already in the
|
|
312
|
+
graph (or about to be added) works.
|
|
313
|
+
|
|
314
|
+
``node_a`` becomes the bond's ``from_element`` and ``node_b``
|
|
315
|
+
the ``to_element`` — just like the positional convention of
|
|
316
|
+
the :class:`Bond` constructor. For two-port elements
|
|
317
|
+
(:class:`Transformer`, :class:`Gyrator`) the existing
|
|
318
|
+
``__handle_bonds`` logic uses this direction to assign
|
|
319
|
+
``bond1`` (to_element) vs ``bond2`` (from_element), so no
|
|
320
|
+
extra ``side`` parameter is needed::
|
|
321
|
+
|
|
322
|
+
# Bond INTO gyrator → bond1 (primary)
|
|
323
|
+
system.connect(junction_node, gyrator, causality)
|
|
324
|
+
|
|
325
|
+
# Bond FROM gyrator → bond2 (secondary)
|
|
326
|
+
system.connect(gyrator, junction_node, causality)
|
|
327
|
+
|
|
328
|
+
Parameters
|
|
329
|
+
----------
|
|
330
|
+
node_a : Node
|
|
331
|
+
Source — becomes ``from_element`` of the new bond.
|
|
332
|
+
node_b : Node
|
|
333
|
+
Destination — becomes ``to_element`` of the new bond.
|
|
334
|
+
causality : Causality, optional
|
|
335
|
+
Causality of the connecting bond. Defaults to
|
|
336
|
+
``Causality.EFFORT_OUT``.
|
|
337
|
+
|
|
338
|
+
Returns
|
|
339
|
+
-------
|
|
340
|
+
Bond
|
|
341
|
+
The newly created connecting bond.
|
|
342
|
+
|
|
343
|
+
Raises
|
|
344
|
+
------
|
|
345
|
+
TypeError
|
|
346
|
+
If an argument is not a Node.
|
|
347
|
+
"""
|
|
348
|
+
if not isinstance(node_a, Node):
|
|
349
|
+
raise TypeError(
|
|
350
|
+
f"node_a must be a Node, got {type(node_a).__name__}"
|
|
351
|
+
)
|
|
352
|
+
if not isinstance(node_b, Node):
|
|
353
|
+
raise TypeError(
|
|
354
|
+
f"node_b must be a Node, got {type(node_b).__name__}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
bond = Bond(
|
|
358
|
+
from_element=node_a,
|
|
359
|
+
to_element=node_b,
|
|
360
|
+
causality=causality,
|
|
361
|
+
)
|
|
362
|
+
self.add_bond(bond)
|
|
363
|
+
return bond
|
|
364
|
+
|
|
258
365
|
def plot(self, layout: Callable[[nx.Graph, ...], dict] = nx.spectral_layout, **kwargs) -> tuple[plt.Figure, plt.Axes]:
|
|
259
366
|
"""Plots the bond graph as a `networkx` graph.
|
|
260
367
|
|
|
@@ -269,6 +376,7 @@ class BondGraph:
|
|
|
269
376
|
-------
|
|
270
377
|
tuple[plt.Figure, plt.Axes]
|
|
271
378
|
Matplotlib Figure and axes objects for the plot.
|
|
379
|
+
|
|
272
380
|
Raises
|
|
273
381
|
------
|
|
274
382
|
ValueError
|
|
@@ -286,19 +394,25 @@ class BondGraph:
|
|
|
286
394
|
bond.to_element.name,
|
|
287
395
|
label=bond.num,
|
|
288
396
|
causality=bond.causality,
|
|
397
|
+
bond=bond,
|
|
289
398
|
)
|
|
290
399
|
|
|
291
|
-
#
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
400
|
+
# Try Graphviz 'dot' layout first — it minimises edge crossings
|
|
401
|
+
# and produces clean left-to-right hierarchical layouts.
|
|
402
|
+
try:
|
|
403
|
+
pos = nx.drawing.nx_agraph.graphviz_layout(
|
|
404
|
+
G, prog="dot",
|
|
405
|
+
args='-Grankdir=LR -Gnodesep=0.8 -Granksep=1.2 -Gordering=out'
|
|
406
|
+
)
|
|
407
|
+
except (ImportError, Exception):
|
|
408
|
+
# Fall back to the previous layout strategy
|
|
409
|
+
if nx.is_directed_acyclic_graph(G):
|
|
410
|
+
for layer, nodes in enumerate(nx.topological_generations(G)):
|
|
411
|
+
for node in nodes:
|
|
412
|
+
G.nodes[node]["layer"] = layer
|
|
413
|
+
pos = nx.multipartite_layout(G, subset_key="layer")
|
|
414
|
+
else:
|
|
415
|
+
pos = layout(G, **kwargs)
|
|
302
416
|
|
|
303
417
|
fig, ax = plt.subplots(figsize=(10, 8))
|
|
304
418
|
ax.set_axis_off()
|
|
@@ -313,13 +427,20 @@ class BondGraph:
|
|
|
313
427
|
font_color="black",
|
|
314
428
|
arrows=True,
|
|
315
429
|
)
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
430
|
+
# Draw bond-number labels at varied positions along each edge so
|
|
431
|
+
# that crossing bonds don't produce overlapping text.
|
|
432
|
+
for edge_idx, (u, v, data) in enumerate(G.edges(data=True)):
|
|
433
|
+
x1, y1 = pos[u]
|
|
434
|
+
x2, y2 = pos[v]
|
|
435
|
+
# Vary the parameter t ∈ [0.35, 0.65] per edge
|
|
436
|
+
t = 0.35 + 0.3 * ((edge_idx * 7 + 3) % 11) / 10.0
|
|
437
|
+
lx = x1 + t * (x2 - x1)
|
|
438
|
+
ly = y1 + t * (y2 - y1)
|
|
439
|
+
ax.text(
|
|
440
|
+
lx, ly, str(data.get("label", "")),
|
|
441
|
+
fontsize=8, ha="center", va="center",
|
|
442
|
+
bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.8),
|
|
443
|
+
)
|
|
323
444
|
|
|
324
445
|
# Function to add a perpendicular line
|
|
325
446
|
def draw_causal_stroke(ax, p1, p2, at="head", length=20, node_size=2000, padding=2):
|
|
@@ -385,15 +506,34 @@ class BondGraph:
|
|
|
385
506
|
|
|
386
507
|
ax.plot([p_start[0], p_end[0]], [p_start[1], p_end[1]], color="k", lw=1.0)
|
|
387
508
|
|
|
388
|
-
#
|
|
509
|
+
# Collect causal-stroke specs so they can be redrawn on resize.
|
|
510
|
+
# One entry per bond
|
|
511
|
+
_stroke_specs: list[tuple] = []
|
|
389
512
|
for u, v, data in G.edges(data=True):
|
|
390
513
|
causality = data.get("causality", None)
|
|
391
|
-
|
|
392
514
|
if causality is Causality.EFFORT_OUT:
|
|
393
|
-
|
|
515
|
+
_stroke_specs.append((pos[u], pos[v], "head", -2))
|
|
394
516
|
elif causality is Causality.FLOW_OUT:
|
|
395
|
-
|
|
517
|
+
_stroke_specs.append((pos[u], pos[v], "tail", -2))
|
|
396
518
|
else:
|
|
397
519
|
raise ValueError(f"Edge {u}->{v} has no valid causality: {causality} --> this should never happen!")
|
|
398
520
|
|
|
521
|
+
_stroke_artists: list[Line2D] = []
|
|
522
|
+
|
|
523
|
+
def _refresh_strokes(event=None):
|
|
524
|
+
"""Recompute causal strokes in current display coords."""
|
|
525
|
+
|
|
526
|
+
for a in _stroke_artists:
|
|
527
|
+
a.remove() #removes artist (Line2D) from axes
|
|
528
|
+
_stroke_artists.clear()
|
|
529
|
+
|
|
530
|
+
for p1, p2, at, padding in _stroke_specs:
|
|
531
|
+
draw_causal_stroke(ax, p1, p2, at=at, padding=padding)
|
|
532
|
+
|
|
533
|
+
# Newly created Line2D artists are the last len(_stroke_specs) items (i.e. # of bonds lines) on ax.lines --> store to remove later
|
|
534
|
+
_stroke_artists.extend(ax.lines[-len(_stroke_specs):])
|
|
535
|
+
|
|
536
|
+
_refresh_strokes() # initial draw
|
|
537
|
+
fig.canvas.mpl_connect("resize_event", _refresh_strokes) # stay correct on resize
|
|
538
|
+
|
|
399
539
|
return fig, ax
|
|
@@ -49,9 +49,9 @@ class Node(ABC):
|
|
|
49
49
|
class Bond:
|
|
50
50
|
"""Represents a bond between two elements in the bond graph."""
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
_counter = 0 # Global fallback counter; prefer BondGraph-scoped numbering
|
|
53
53
|
|
|
54
|
-
def __init__(self, from_element: Node, to_element: Node, causality: str | Causality):
|
|
54
|
+
def __init__(self, from_element: Node, to_element: Node, causality: str | Causality, num: int | None = None, instance_name: str = "", is_prefix: bool = True):
|
|
55
55
|
"""Create a bond between two elements with specified causality.
|
|
56
56
|
The positive direction of this power bond is from `from_element` to `to_element`.
|
|
57
57
|
Efforts and flows are represented by symbolic `sympy.Symbol`s that are strictly real-valued.
|
|
@@ -70,6 +70,14 @@ class Bond:
|
|
|
70
70
|
`OneJunction` imposes effort on the `Inductor`, meaning it has an equivalent `effort_in` causality.
|
|
71
71
|
Likewise a `Bond(OneJunction(...), Capacitor(...), "flow_out")` means that the `OneJunction` imposes
|
|
72
72
|
flow on the `Capacitor`, meaning it has an equivalent `flow_in`/`effort_out` causality.
|
|
73
|
+
num : int | None, optional
|
|
74
|
+
Explicit bond number. If None, the global fallback counter is used.
|
|
75
|
+
When bonds are added to a BondGraph, the graph manages numbering.
|
|
76
|
+
instance_name : str, optional
|
|
77
|
+
Instance name for symbol names (e.g. "Motor_"). Used when merging sub-models
|
|
78
|
+
to avoid symbol collisions.
|
|
79
|
+
is_prefix : bool, optional
|
|
80
|
+
Whether to use the instance name as a prefix or suffix. Defaults to True.
|
|
73
81
|
|
|
74
82
|
Raises
|
|
75
83
|
------
|
|
@@ -86,17 +94,64 @@ class Bond:
|
|
|
86
94
|
|
|
87
95
|
self.causality: Causality = causality
|
|
88
96
|
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
if num is None:
|
|
98
|
+
self.num = Bond._counter
|
|
99
|
+
Bond._counter += 1
|
|
100
|
+
else:
|
|
101
|
+
self.num = num
|
|
91
102
|
|
|
92
|
-
self.
|
|
93
|
-
|
|
103
|
+
self.instance_name = instance_name
|
|
104
|
+
|
|
105
|
+
if is_prefix:
|
|
106
|
+
self.effort = sp.Symbol(f"e_{self.instance_name}{self.num}", real=True)
|
|
107
|
+
self.flow = sp.Symbol(f"f_{self.instance_name}{self.num}", real=True)
|
|
108
|
+
else:
|
|
109
|
+
self.effort = sp.Symbol(f"e_{self.num}_{self.instance_name}", real=True)
|
|
110
|
+
self.flow = sp.Symbol(f"f_{self.num}_{self.instance_name}", real=True)
|
|
94
111
|
|
|
95
112
|
@property
|
|
96
113
|
def elements(self) -> tuple[Node, Node]:
|
|
97
114
|
"""tuple[Node, Node]: The two elements connected by the bond. First element is `from_element`, second is `to_element`."""
|
|
98
115
|
return (self.from_element, self.to_element)
|
|
99
116
|
|
|
117
|
+
def rename_symbols(self, new_num: int | None = None, new_instance_name: str | None = None, is_prefix: bool = True) -> dict[sp.Symbol, sp.Symbol]:
|
|
118
|
+
"""Rename the effort/flow symbols of this bond and return the substitution map.
|
|
119
|
+
This is used during sub-model merging to avoid symbol collisions.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
new_num : int | None, optional
|
|
124
|
+
New bond number. If None, keeps the current number.
|
|
125
|
+
new_instance_name : str | None, optional
|
|
126
|
+
New instance name. If None, keeps the current instance name.
|
|
127
|
+
is_prefix : bool, optional
|
|
128
|
+
Whether to use the instance name as a prefix or suffix. Defaults to True.
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
dict[sp.Symbol, sp.Symbol]
|
|
133
|
+
Mapping from old symbols to new symbols, for use with `sp.Expr.subs()`.
|
|
134
|
+
"""
|
|
135
|
+
old_effort = self.effort
|
|
136
|
+
old_flow = self.flow
|
|
137
|
+
|
|
138
|
+
if new_num is not None:
|
|
139
|
+
self.num = new_num
|
|
140
|
+
if new_instance_name is not None:
|
|
141
|
+
self.instance_name = new_instance_name
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if is_prefix:
|
|
145
|
+
padded_name = "_" + self.instance_name + "_" if self.instance_name != "" else "_"
|
|
146
|
+
self.effort = sp.Symbol(f"e{padded_name}{self.num}", real=True)
|
|
147
|
+
self.flow = sp.Symbol(f"f{padded_name}{self.num}", real=True)
|
|
148
|
+
else:
|
|
149
|
+
padded_name = self.instance_name if self.instance_name != "" else "" # conditional can be skipped
|
|
150
|
+
self.effort = sp.Symbol(f"e_{self.num}{padded_name}", real=True)
|
|
151
|
+
self.flow = sp.Symbol(f"f_{self.num}{padded_name}", real=True)
|
|
152
|
+
|
|
153
|
+
return {old_effort: self.effort, old_flow: self.flow}
|
|
154
|
+
|
|
100
155
|
def __repr__(self) -> str:
|
|
101
156
|
return f"Bond(from={self.from_element}, to={self.to_element}, causality={self.causality})"
|
|
102
157
|
|
|
@@ -186,3 +241,7 @@ class ElementTwoPort(Node, ABC):
|
|
|
186
241
|
|
|
187
242
|
def __repr__(self) -> str:
|
|
188
243
|
return f"{self.__class__.__name__}(name = {self.name}, value = {self.value})"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# Type alias — a port mapping is simply {name: node, ...}.
|
|
247
|
+
type Port = dict[str, Node]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""SubBondGraph — a reusable, connectable sub-model.
|
|
2
|
+
|
|
3
|
+
A SubBondGraph wraps a BondGraph together with named ports.
|
|
4
|
+
It can be instantiated (deep-copied with namespace prefixing) into
|
|
5
|
+
a parent BondGraph, and two sub-models can be connected at their
|
|
6
|
+
ports via a new bond.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import sympy as sp
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .bondgraph import BondGraph
|
|
20
|
+
|
|
21
|
+
from .core import (
|
|
22
|
+
Bond,
|
|
23
|
+
Causality,
|
|
24
|
+
Node,
|
|
25
|
+
Junction,
|
|
26
|
+
ElementOnePort,
|
|
27
|
+
ElementTwoPort,
|
|
28
|
+
StatefulElement,
|
|
29
|
+
)
|
|
30
|
+
from .core import Port
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SubBondGraph:
|
|
34
|
+
"""A reusable bond-graph sub-model with named connection ports.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
name : str
|
|
39
|
+
Unique name for this sub-model. Used as namespace prefix
|
|
40
|
+
when the sub-model is instantiated into a parent graph
|
|
41
|
+
(e.g. ``"Motor"`` produces symbols like ``e_Motor_0``).
|
|
42
|
+
bondgraph : BondGraph
|
|
43
|
+
The internal bond graph that defines this sub-model's
|
|
44
|
+
physics.
|
|
45
|
+
ports : dict[str, Node]
|
|
46
|
+
Named mapping of port names to boundary :class:`Node` objects
|
|
47
|
+
(e.g. ``{"in": j1_mech, "out": gyrator}``). The type alias
|
|
48
|
+
:data:`Port` is provided for convenience.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, name: str, bondgraph: BondGraph, ports: Port):
|
|
52
|
+
self.name = name
|
|
53
|
+
self.bondgraph = bondgraph
|
|
54
|
+
self.ports = ports
|
|
55
|
+
|
|
56
|
+
# Validate that every port node is actually in the bondgraph
|
|
57
|
+
bg_elements = set(bondgraph.elements)
|
|
58
|
+
for port_name, node in ports.items():
|
|
59
|
+
if node not in bg_elements:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Port '{port_name}' references node "
|
|
62
|
+
f"{node} which is not in the bond graph."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Instantiation (deep-copy with namespace prefixing)
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _instantiate(self, parent_bondgraph: BondGraph, instance_name: str | None = None, is_prefix: bool = True) -> Port:
|
|
70
|
+
"""Create a namespace-prefixed copy and merge it into *parent_bondgraph*.
|
|
71
|
+
|
|
72
|
+
.. note::
|
|
73
|
+
This is an internal method. Prefer
|
|
74
|
+
:meth:`BondGraph.add_subbondgraph` which delegates here
|
|
75
|
+
and provides a cleaner, graph-centric API::
|
|
76
|
+
|
|
77
|
+
ports = system.add_subbondgraph(sub, "inst1")
|
|
78
|
+
|
|
79
|
+
Every element and bond is deep-copied. Element names and bond
|
|
80
|
+
symbols are prefixed with ``instance_name + "_"`` so that
|
|
81
|
+
multiple instances of the same sub-model can coexist without
|
|
82
|
+
symbol collisions.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
parent_bondgraph : BondGraph
|
|
87
|
+
The parent graph to merge into.
|
|
88
|
+
instance_name : str | None, optional
|
|
89
|
+
Override the namespace prefix. Defaults to ``self.name``.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
Port
|
|
94
|
+
A mapping of port names to *new* Node objects whose
|
|
95
|
+
elements belong to the parent graph. Use these for
|
|
96
|
+
subsequent :meth:`BondGraph.connect` calls.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
if instance_name is None:
|
|
100
|
+
instance_name = self.name
|
|
101
|
+
|
|
102
|
+
def get_new_name(orig_name: str) -> str:
|
|
103
|
+
"""Return a new name for an element or symbol, with prefix/suffix."""
|
|
104
|
+
if is_prefix:
|
|
105
|
+
return instance_name + "_" + orig_name
|
|
106
|
+
else:
|
|
107
|
+
return orig_name + "_" + instance_name
|
|
108
|
+
|
|
109
|
+
# 1. Deep-copy all elements and build old→new mapping
|
|
110
|
+
elem_map: dict[int, Node] = {} # id(old_element) → new_element
|
|
111
|
+
for elem in self.bondgraph.elements:
|
|
112
|
+
new_elem = copy.deepcopy(elem)
|
|
113
|
+
new_elem.name = get_new_name(new_elem.name)
|
|
114
|
+
|
|
115
|
+
# Prefix the symbolic value for one-port and two-port elements
|
|
116
|
+
# (skip sensors whose value is empty/unused)
|
|
117
|
+
if isinstance(new_elem, ElementOnePort):
|
|
118
|
+
old_val = new_elem.value
|
|
119
|
+
if str(old_val): # skip empty-value sensors
|
|
120
|
+
new_elem.value = sp.Symbol(
|
|
121
|
+
get_new_name(str(old_val)),
|
|
122
|
+
real=True, positive=True,
|
|
123
|
+
)
|
|
124
|
+
new_elem.bond = None # will be re-wired by parent
|
|
125
|
+
|
|
126
|
+
elif isinstance(new_elem, ElementTwoPort):
|
|
127
|
+
old_val = new_elem.value
|
|
128
|
+
new_elem.value = sp.Symbol(
|
|
129
|
+
get_new_name(str(old_val)),
|
|
130
|
+
real=True, positive=True,
|
|
131
|
+
)
|
|
132
|
+
new_elem.bond1 = None
|
|
133
|
+
new_elem.bond2 = None
|
|
134
|
+
|
|
135
|
+
elif isinstance(new_elem, Junction):
|
|
136
|
+
new_elem.bonds = []
|
|
137
|
+
new_elem.strong_bond = None
|
|
138
|
+
|
|
139
|
+
elem_map[id(elem)] = new_elem
|
|
140
|
+
|
|
141
|
+
# 2. Deep-copy bonds, re-pointing to new elements and prefixing symbols
|
|
142
|
+
for bond in self.bondgraph.bonds:
|
|
143
|
+
new_from = elem_map[id(bond.from_element)]
|
|
144
|
+
new_to = elem_map[id(bond.to_element)]
|
|
145
|
+
new_bond = Bond(
|
|
146
|
+
from_element=new_from,
|
|
147
|
+
to_element=new_to,
|
|
148
|
+
causality=bond.causality,
|
|
149
|
+
instance_name=instance_name,
|
|
150
|
+
is_prefix=is_prefix
|
|
151
|
+
)
|
|
152
|
+
parent_bondgraph.add_bond(new_bond)
|
|
153
|
+
|
|
154
|
+
# 3. Build new port mapping
|
|
155
|
+
new_ports: Port = {}
|
|
156
|
+
for port_name, node in self.ports.items():
|
|
157
|
+
new_ports[port_name] = elem_map[id(node)]
|
|
158
|
+
|
|
159
|
+
return new_ports
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Serialization
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def save(self, filepath: str | Path) -> None:
|
|
166
|
+
"""Save this sub-model definition to a JSON file.
|
|
167
|
+
|
|
168
|
+
The file stores the structural description (element types,
|
|
169
|
+
names, values, bond connectivity, causality, ports) — enough
|
|
170
|
+
to reconstruct the sub-model from scratch.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
filepath : str | Path
|
|
175
|
+
Destination file path (typically ``*.json``).
|
|
176
|
+
"""
|
|
177
|
+
data = self._to_dict()
|
|
178
|
+
Path(filepath).write_text(
|
|
179
|
+
json.dumps(data, indent=2, ensure_ascii=False),
|
|
180
|
+
encoding="utf-8",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def load(cls, filepath: str | Path) -> SubBondGraph:
|
|
185
|
+
"""Load a sub-model definition from a JSON file.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
filepath : str | Path
|
|
190
|
+
Source file path.
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
SubBondGraph
|
|
195
|
+
The reconstructed sub-model.
|
|
196
|
+
"""
|
|
197
|
+
data = json.loads(Path(filepath).read_text(encoding="utf-8"))
|
|
198
|
+
return cls._from_dict(data)
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
# Internal serialization helpers
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _to_dict(self) -> dict:
|
|
205
|
+
"""Convert this sub-model to a JSON-serializable dict."""
|
|
206
|
+
from .elements import (
|
|
207
|
+
SourceEffort, SourceFlow, Capacitor, Inductor,
|
|
208
|
+
Resistor, Transformer, Gyrator,
|
|
209
|
+
OneJunction, ZeroJunction,
|
|
210
|
+
)
|
|
211
|
+
from .sensors import IntegratedEffortSensor, IntegratedFlowSensor
|
|
212
|
+
|
|
213
|
+
# Map each element to an index for bond connectivity
|
|
214
|
+
elem_index: dict[int, int] = {}
|
|
215
|
+
elements_data = []
|
|
216
|
+
for i, elem in enumerate(self.bondgraph.elements):
|
|
217
|
+
elem_index[id(elem)] = i
|
|
218
|
+
entry = {
|
|
219
|
+
"type": type(elem).__name__,
|
|
220
|
+
"name": elem.name,
|
|
221
|
+
}
|
|
222
|
+
if isinstance(elem, (ElementOnePort, ElementTwoPort)):
|
|
223
|
+
if not isinstance(elem, (IntegratedEffortSensor, IntegratedFlowSensor)):
|
|
224
|
+
entry["value"] = str(elem.value)
|
|
225
|
+
elements_data.append(entry)
|
|
226
|
+
|
|
227
|
+
bonds_data = []
|
|
228
|
+
for bond in self.bondgraph.bonds:
|
|
229
|
+
bonds_data.append({
|
|
230
|
+
"from": elem_index[id(bond.from_element)],
|
|
231
|
+
"to": elem_index[id(bond.to_element)],
|
|
232
|
+
"causality": bond.causality.value,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
ports_data = {}
|
|
236
|
+
for port_name, node in self.ports.items():
|
|
237
|
+
ports_data[port_name] = {
|
|
238
|
+
"node_index": elem_index[id(node)],
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
"name": self.name,
|
|
243
|
+
"elements": elements_data,
|
|
244
|
+
"bonds": bonds_data,
|
|
245
|
+
"ports": ports_data,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def _from_dict(cls, data: dict) -> SubBondGraph:
|
|
250
|
+
"""Reconstruct a SubBondGraph from a dict (inverse of ``_to_dict``)."""
|
|
251
|
+
from .bondgraph import BondGraph
|
|
252
|
+
from .elements import (
|
|
253
|
+
SourceEffort, SourceFlow, Capacitor, Inductor,
|
|
254
|
+
Resistor, Transformer, Gyrator,
|
|
255
|
+
OneJunction, ZeroJunction,
|
|
256
|
+
)
|
|
257
|
+
from .sensors import IntegratedEffortSensor, IntegratedFlowSensor
|
|
258
|
+
|
|
259
|
+
# Element type registry
|
|
260
|
+
TYPE_MAP = {
|
|
261
|
+
"SourceEffort": SourceEffort,
|
|
262
|
+
"SourceFlow": SourceFlow,
|
|
263
|
+
"Capacitor": Capacitor,
|
|
264
|
+
"Compliance": Capacitor,
|
|
265
|
+
"Inductor": Inductor,
|
|
266
|
+
"Inertance": Inductor,
|
|
267
|
+
"Resistor": Resistor,
|
|
268
|
+
"Resistance": Resistor,
|
|
269
|
+
"Transformer": Transformer,
|
|
270
|
+
"Gyrator": Gyrator,
|
|
271
|
+
"OneJunction": OneJunction,
|
|
272
|
+
"ZeroJunction": ZeroJunction,
|
|
273
|
+
"IntegratedEffortSensor": IntegratedEffortSensor,
|
|
274
|
+
"IntegratedFlowSensor": IntegratedFlowSensor,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# Reconstruct elements
|
|
278
|
+
elements = []
|
|
279
|
+
for entry in data["elements"]:
|
|
280
|
+
elem_cls = TYPE_MAP[entry["type"]]
|
|
281
|
+
if issubclass(elem_cls, Junction):
|
|
282
|
+
elem = elem_cls(name=entry["name"])
|
|
283
|
+
elif issubclass(elem_cls, (IntegratedEffortSensor, IntegratedFlowSensor)):
|
|
284
|
+
elem = elem_cls(name=entry["name"])
|
|
285
|
+
else:
|
|
286
|
+
elem = elem_cls(name=entry["name"], value=entry["value"])
|
|
287
|
+
elements.append(elem)
|
|
288
|
+
|
|
289
|
+
# Reconstruct bonds and build BondGraph
|
|
290
|
+
bg = BondGraph(name=data["name"])
|
|
291
|
+
for bond_entry in data["bonds"]:
|
|
292
|
+
bond = Bond(
|
|
293
|
+
from_element=elements[bond_entry["from"]],
|
|
294
|
+
to_element=elements[bond_entry["to"]],
|
|
295
|
+
causality=bond_entry["causality"],
|
|
296
|
+
)
|
|
297
|
+
bg.add_bond(bond)
|
|
298
|
+
|
|
299
|
+
# Reconstruct ports
|
|
300
|
+
ports: Port = {}
|
|
301
|
+
for port_name, port_data in data["ports"].items():
|
|
302
|
+
# Support both old "junction_index" and new "node_index" keys
|
|
303
|
+
idx = port_data.get("node_index", port_data.get("junction_index"))
|
|
304
|
+
ports[port_name] = elements[idx]
|
|
305
|
+
|
|
306
|
+
return cls(name=data["name"], bondgraph=bg, ports=ports)
|
|
307
|
+
|
|
308
|
+
def __repr__(self) -> str:
|
|
309
|
+
port_names = list(self.ports.keys())
|
|
310
|
+
return (
|
|
311
|
+
f"SubBondGraph(name={self.name!r}, "
|
|
312
|
+
f"elements={len(self.bondgraph.elements)}, "
|
|
313
|
+
f"bonds={len(self.bondgraph.bonds)}, "
|
|
314
|
+
f"ports={port_names})"
|
|
315
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyBondGraph
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Modelling tool for linear bond graph systems in Python
|
|
5
5
|
Author-email: Matthias Panny <matthias.panny@mci.edu>
|
|
6
6
|
License-Expression: CC-BY-NC-SA-4.0
|
|
@@ -44,12 +44,15 @@ Bond graphs provide a **domain-independent modeling framework** for physical sys
|
|
|
44
44
|
|
|
45
45
|
# Installation
|
|
46
46
|
|
|
47
|
-
## Install
|
|
48
|
-
The easiest way to install the library is directly
|
|
47
|
+
## Install from PyPI or Github
|
|
48
|
+
The easiest way to install the library is via your preferred package manager (e.g. pip) directly from PyPI:
|
|
49
|
+
```bash
|
|
50
|
+
pip install pyBondGraph
|
|
51
|
+
```
|
|
52
|
+
Alternatively one can install the latest development version directly from the GitHub repository:
|
|
49
53
|
```bash
|
|
50
54
|
pip install git+https://github.com/MrP123/pyBondGraph.git
|
|
51
55
|
```
|
|
52
|
-
This installs the latest version of the package from the repository.
|
|
53
56
|
|
|
54
57
|
---
|
|
55
58
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|