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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyBondGraph
3
- Version: 0.1.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 directly from GitHub
48
- The easiest way to install the library is directly via pip:
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 directly from GitHub
22
- The easiest way to install the library is directly via pip:
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
- Bond.counter = 0 # Reset bond counter for future bond graphs after solution has been computed --> ToDo: fix this hacky solution
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.elements = []
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
- # 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)
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
- 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
- )
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
- # Add perpendicular lines to edges
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
- draw_causal_stroke(ax, pos[u], pos[v], at="head", padding=-2)
515
+ _stroke_specs.append((pos[u], pos[v], "head", -2))
394
516
  elif causality is Causality.FLOW_OUT:
395
- draw_causal_stroke(ax, pos[u], pos[v], at="tail", padding=-2)
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
- counter = 0
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
- self.num = Bond.counter
90
- Bond.counter += 1
97
+ if num is None:
98
+ self.num = Bond._counter
99
+ Bond._counter += 1
100
+ else:
101
+ self.num = num
91
102
 
92
- self.effort = sp.Symbol(f"e_{self.num}", real=True)
93
- self.flow = sp.Symbol(f"f_{self.num}", real=True)
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.1.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 directly from GitHub
48
- The easiest way to install the library is directly via pip:
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
 
@@ -7,6 +7,7 @@ pyBondGraph/bondgraph.py
7
7
  pyBondGraph/core.py
8
8
  pyBondGraph/elements.py
9
9
  pyBondGraph/sensors.py
10
+ pyBondGraph/subbondgraph.py
10
11
  pyBondGraph.egg-info/PKG-INFO
11
12
  pyBondGraph.egg-info/SOURCES.txt
12
13
  pyBondGraph.egg-info/dependency_links.txt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyBondGraph"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Modelling tool for linear bond graph systems in Python"
5
5
  readme = "README.md"
6
6
  authors = [
File without changes
File without changes