pyBondGraph 0.2.0__tar.gz → 0.3.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.2.0
3
+ Version: 0.3.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
@@ -36,9 +36,15 @@ Bond graphs provide a **domain-independent modeling framework** for physical sys
36
36
  # Features
37
37
 
38
38
  * Programmatic construction of **bond graph models**
39
+ * **Automatic causality assignment** via SCAP (Sequential Causality Assignment Procedure), with optional manual override or mixed mode
39
40
  * Automatic **symbolic equation derivation** using SymPy
40
- * Conversion of models to **state-space systems**
41
- * Example models for electrical and mechanical systems
41
+ * Conversion of models to **linear state-space systems** ($\dot{x} = Ax + Bu$, $y = Cx + Du$)
42
+ * **Composable sub-models** via `SubBondGraph` with deep-copy namespace isolation
43
+ * **Two-port elements**: Transformer and Gyrator with automatic causality propagation
44
+ * **Sensor elements**: `IntegratedEffortSensor` and `IntegratedFlowSensor` for measuring integrated generalized variables (e.g. position from velocity)
45
+ * **Domain-neutral aliases**: `Compliance` = `Capacitor`, `Inertance` = `Inductor`, `Resistance` = `Resistor`
46
+ * Example models for electrical and electromechanical systems
47
+ * Integration with **python-control** for numerical simulation (step response, Bode plots, etc.)
42
48
 
43
49
  ---
44
50
 
@@ -81,12 +87,12 @@ Optional dependencies are used for experimental visualization tools.
81
87
  ---
82
88
 
83
89
  # Basic Usage
84
- A bond graph model is constructed by creating elements and connecting them via bonds.
90
+ A bond graph model is constructed by creating elements and connecting them via the `connect()` convenience method, which creates bonds and adds them to the graph in one step.
85
91
 
86
- Simple RC-Filter circuit:
92
+ ## RC-Filter with automatic causality (SCAP)
87
93
 
88
94
  ```python
89
- from pyBondGraph import BondGraph, SourceEffort, Resistor, Capacitor, OneJunction, Bond, Causality
95
+ from pyBondGraph import BondGraph, SourceEffort, Resistor, Capacitor, OneJunction
90
96
 
91
97
  bg = BondGraph()
92
98
 
@@ -96,19 +102,20 @@ resistor = Resistor("R", "R")
96
102
  capacitor = Capacitor("C", "C")
97
103
  series_junction = OneJunction("J1")
98
104
 
99
- # connect elements
100
- # causalities need to be assigned manually
101
- bg.add_bond(Bond(voltage_source, series_junction, Causality.EFFORT_OUT))
102
- bg.add_bond(Bond(series_junction, resistor, Causality.EFFORT_OUT))
103
- bg.add_bond(Bond(series_junction, capacitor, Causality.FLOW_OUT))
105
+ # connect elements --> causality is assigned automatically by SCAP
106
+ bg.connect(voltage_source, series_junction)
107
+ bg.connect(series_junction, resistor)
108
+ bg.connect(series_junction, capacitor)
104
109
 
105
110
  # plot the resulting BondGraph
106
111
  bg.plot()
107
112
 
108
113
  # derive system equations in linear state space form
109
- A, B, C, D, x, n_states, n_inputs, n_outputs = bond_graph.get_state_space()
114
+ A, B, C, D, x, n_states, n_inputs, n_outputs = bg.get_state_space()
110
115
  ```
111
116
 
117
+ Causality can also be assigned **manually** by passing a `Causality` value to `connect()`, or in **mixed mode** where some bonds are fixed and SCAP resolves the rest.
118
+
112
119
  The library automatically derives the **symbolic system equations** describing the dynamics of the model.
113
120
 
114
121
  ---
@@ -126,6 +133,12 @@ Bond graphs represent **power exchange between system components**, where power
126
133
  | Se | Effort source |
127
134
  | Sf | Flow source |
128
135
 
136
+ ## Two-Port Elements
137
+ | Element | Meaning |
138
+ |-------------|------------------------------------------------------------|
139
+ | TF | Transformer — same causality on both bonds |
140
+ | GY | Gyrator — opposite causality on both bonds |
141
+
129
142
  ## Junctions
130
143
  | Junction | Meaning |
131
144
  |----------|---------------|
@@ -139,11 +152,9 @@ Bond graphs represent **power exchange between system components**, where power
139
152
  | IntegratedFlowSensor | Measures integral of the flow at its bond |
140
153
 
141
154
  In mechanical bond graph models:
142
- * **flow** corresponds to **velocity**
155
+ * **flow** corresponds to **velocity**, i.e. an *IntegratedFlowSensor* can be used to compute **position**.
143
156
  * **effort** corresponds to **force**
144
157
 
145
- An **integrated flow sensor** can therefore be used to compute **position**:
146
-
147
158
  ---
148
159
 
149
160
  # Example Systems
@@ -153,7 +164,7 @@ The repository contains example models illustrating typical applications of bond
153
164
  Demonstrates modeling of an electrical circuit using bond graph elements.
154
165
 
155
166
  ### DC Motor
156
- A multi-domain electromechanical system coupling electrical and mechanical dynamics.
167
+ A multi-domain electromechanical system coupling electrical and mechanical dynamics. Also demonstrates integration with the `python-control` package for numerical simulation (step response).
157
168
 
158
169
  ### Transformer
159
170
  Example of energy transformation between two ports.
@@ -163,6 +174,18 @@ Classical mass-spring-damper system with two degrees of freedom.
163
174
 
164
175
  ---
165
176
 
177
+ # Causality Assignment
178
+
179
+ pyBondGraph supports three modes for assigning causality:
180
+
181
+ 1. **Automatic (SCAP)** — omit causality in `connect()` calls; `assign_causality()` is called automatically when solving. The Sequential Causality Assignment Procedure assigns causality in priority order: sources, storage elements (integral causality), resistors, then propagation through junctions and two-port elements.
182
+ 2. **Manual** — pass `Causality.EFFORT_OUT` or `Causality.FLOW_OUT` explicitly to each `connect()` call.
183
+ 3. **Mixed** — fix causality on some bonds, let SCAP resolve the rest.
184
+
185
+ If a storage element cannot receive integral causality (which would imply a DAE rather than an ODE), a `DerivativeCausalityError` is raised with a clear diagnostic message.
186
+
187
+ ---
188
+
166
189
  # Typical Applications
167
190
  Bond graph modeling is particularly useful for:
168
191
 
@@ -171,3 +194,11 @@ Bond graph modeling is particularly useful for:
171
194
  * multi-domain energy systems
172
195
  * control system modeling
173
196
  * teaching system dynamics
197
+
198
+ ---
199
+
200
+ # Planned Features
201
+
202
+ * **FMU Export**: export bond graph models as Functional Mock-up Units (FMI standard) for interoperability with Simulink, Dymola, OpenModelica, and other FMI-compliant tools
203
+ * **Nonlinear element support**: general nonlinear constitutive laws with Jacobian linearization
204
+ * **Convenience bridge to python-control**: `to_control_ss(params)` method wrapping the existing manual pattern
@@ -10,9 +10,15 @@ Bond graphs provide a **domain-independent modeling framework** for physical sys
10
10
  # Features
11
11
 
12
12
  * Programmatic construction of **bond graph models**
13
+ * **Automatic causality assignment** via SCAP (Sequential Causality Assignment Procedure), with optional manual override or mixed mode
13
14
  * Automatic **symbolic equation derivation** using SymPy
14
- * Conversion of models to **state-space systems**
15
- * Example models for electrical and mechanical systems
15
+ * Conversion of models to **linear state-space systems** ($\dot{x} = Ax + Bu$, $y = Cx + Du$)
16
+ * **Composable sub-models** via `SubBondGraph` with deep-copy namespace isolation
17
+ * **Two-port elements**: Transformer and Gyrator with automatic causality propagation
18
+ * **Sensor elements**: `IntegratedEffortSensor` and `IntegratedFlowSensor` for measuring integrated generalized variables (e.g. position from velocity)
19
+ * **Domain-neutral aliases**: `Compliance` = `Capacitor`, `Inertance` = `Inductor`, `Resistance` = `Resistor`
20
+ * Example models for electrical and electromechanical systems
21
+ * Integration with **python-control** for numerical simulation (step response, Bode plots, etc.)
16
22
 
17
23
  ---
18
24
 
@@ -55,12 +61,12 @@ Optional dependencies are used for experimental visualization tools.
55
61
  ---
56
62
 
57
63
  # Basic Usage
58
- A bond graph model is constructed by creating elements and connecting them via bonds.
64
+ A bond graph model is constructed by creating elements and connecting them via the `connect()` convenience method, which creates bonds and adds them to the graph in one step.
59
65
 
60
- Simple RC-Filter circuit:
66
+ ## RC-Filter with automatic causality (SCAP)
61
67
 
62
68
  ```python
63
- from pyBondGraph import BondGraph, SourceEffort, Resistor, Capacitor, OneJunction, Bond, Causality
69
+ from pyBondGraph import BondGraph, SourceEffort, Resistor, Capacitor, OneJunction
64
70
 
65
71
  bg = BondGraph()
66
72
 
@@ -70,19 +76,20 @@ resistor = Resistor("R", "R")
70
76
  capacitor = Capacitor("C", "C")
71
77
  series_junction = OneJunction("J1")
72
78
 
73
- # connect elements
74
- # causalities need to be assigned manually
75
- bg.add_bond(Bond(voltage_source, series_junction, Causality.EFFORT_OUT))
76
- bg.add_bond(Bond(series_junction, resistor, Causality.EFFORT_OUT))
77
- bg.add_bond(Bond(series_junction, capacitor, Causality.FLOW_OUT))
79
+ # connect elements --> causality is assigned automatically by SCAP
80
+ bg.connect(voltage_source, series_junction)
81
+ bg.connect(series_junction, resistor)
82
+ bg.connect(series_junction, capacitor)
78
83
 
79
84
  # plot the resulting BondGraph
80
85
  bg.plot()
81
86
 
82
87
  # derive system equations in linear state space form
83
- A, B, C, D, x, n_states, n_inputs, n_outputs = bond_graph.get_state_space()
88
+ A, B, C, D, x, n_states, n_inputs, n_outputs = bg.get_state_space()
84
89
  ```
85
90
 
91
+ Causality can also be assigned **manually** by passing a `Causality` value to `connect()`, or in **mixed mode** where some bonds are fixed and SCAP resolves the rest.
92
+
86
93
  The library automatically derives the **symbolic system equations** describing the dynamics of the model.
87
94
 
88
95
  ---
@@ -100,6 +107,12 @@ Bond graphs represent **power exchange between system components**, where power
100
107
  | Se | Effort source |
101
108
  | Sf | Flow source |
102
109
 
110
+ ## Two-Port Elements
111
+ | Element | Meaning |
112
+ |-------------|------------------------------------------------------------|
113
+ | TF | Transformer — same causality on both bonds |
114
+ | GY | Gyrator — opposite causality on both bonds |
115
+
103
116
  ## Junctions
104
117
  | Junction | Meaning |
105
118
  |----------|---------------|
@@ -113,11 +126,9 @@ Bond graphs represent **power exchange between system components**, where power
113
126
  | IntegratedFlowSensor | Measures integral of the flow at its bond |
114
127
 
115
128
  In mechanical bond graph models:
116
- * **flow** corresponds to **velocity**
129
+ * **flow** corresponds to **velocity**, i.e. an *IntegratedFlowSensor* can be used to compute **position**.
117
130
  * **effort** corresponds to **force**
118
131
 
119
- An **integrated flow sensor** can therefore be used to compute **position**:
120
-
121
132
  ---
122
133
 
123
134
  # Example Systems
@@ -127,7 +138,7 @@ The repository contains example models illustrating typical applications of bond
127
138
  Demonstrates modeling of an electrical circuit using bond graph elements.
128
139
 
129
140
  ### DC Motor
130
- A multi-domain electromechanical system coupling electrical and mechanical dynamics.
141
+ A multi-domain electromechanical system coupling electrical and mechanical dynamics. Also demonstrates integration with the `python-control` package for numerical simulation (step response).
131
142
 
132
143
  ### Transformer
133
144
  Example of energy transformation between two ports.
@@ -137,6 +148,18 @@ Classical mass-spring-damper system with two degrees of freedom.
137
148
 
138
149
  ---
139
150
 
151
+ # Causality Assignment
152
+
153
+ pyBondGraph supports three modes for assigning causality:
154
+
155
+ 1. **Automatic (SCAP)** — omit causality in `connect()` calls; `assign_causality()` is called automatically when solving. The Sequential Causality Assignment Procedure assigns causality in priority order: sources, storage elements (integral causality), resistors, then propagation through junctions and two-port elements.
156
+ 2. **Manual** — pass `Causality.EFFORT_OUT` or `Causality.FLOW_OUT` explicitly to each `connect()` call.
157
+ 3. **Mixed** — fix causality on some bonds, let SCAP resolve the rest.
158
+
159
+ If a storage element cannot receive integral causality (which would imply a DAE rather than an ODE), a `DerivativeCausalityError` is raised with a clear diagnostic message.
160
+
161
+ ---
162
+
140
163
  # Typical Applications
141
164
  Bond graph modeling is particularly useful for:
142
165
 
@@ -144,4 +167,12 @@ Bond graph modeling is particularly useful for:
144
167
  * robotics and mechatronics
145
168
  * multi-domain energy systems
146
169
  * control system modeling
147
- * teaching system dynamics
170
+ * teaching system dynamics
171
+
172
+ ---
173
+
174
+ # Planned Features
175
+
176
+ * **FMU Export**: export bond graph models as Functional Mock-up Units (FMI standard) for interoperability with Simulink, Dymola, OpenModelica, and other FMI-compliant tools
177
+ * **Nonlinear element support**: general nonlinear constitutive laws with Jacobian linearization
178
+ * **Convenience bridge to python-control**: `to_control_ss(params)` method wrapping the existing manual pattern
@@ -1,4 +1,4 @@
1
- from .core import Bond, Causality
1
+ from .core import Bond, Causality, CausalityError, DerivativeCausalityError, Port # port is a type alias: dict[str, Node]
2
2
  from .elements import (
3
3
  SourceEffort,
4
4
  SourceFlow,
@@ -15,7 +15,6 @@ from .elements import (
15
15
  )
16
16
  from .sensors import IntegratedEffortSensor, IntegratedFlowSensor
17
17
  from .subbondgraph import SubBondGraph
18
- from .core import Port # type alias: dict[str, Node]
19
18
 
20
19
  from .bondgraph import BondGraph
21
20
 
@@ -39,4 +38,6 @@ __all__ = [
39
38
  "IntegratedFlowSensor",
40
39
  "Port",
41
40
  "SubBondGraph",
41
+ "CausalityError",
42
+ "DerivativeCausalityError",
42
43
  ]
@@ -10,16 +10,8 @@ import matplotlib.pyplot as plt
10
10
  from collections.abc import Callable
11
11
  from typing import TYPE_CHECKING
12
12
 
13
- from .core import (
14
- Causality,
15
- Node,
16
- StatefulElement,
17
- Bond,
18
- ElementOnePort,
19
- ElementTwoPort,
20
- Junction,
21
- )
22
- from .elements import SourceEffort, SourceFlow, OneJunction, ZeroJunction
13
+ from .core import Causality, CausalityError, DerivativeCausalityError, Node, StatefulElement, Bond, ElementOnePort, ElementTwoPort, Junction
14
+ from .elements import SourceEffort, SourceFlow, Capacitor, Inductor, Resistor, Transformer, Gyrator, OneJunction, ZeroJunction
23
15
 
24
16
  from .core import Port
25
17
 
@@ -94,58 +86,52 @@ class BondGraph:
94
86
  def __handle_bonds(self) -> None:
95
87
  """Handles the bonds in the bond graph by assigning them to the appropriate elements.
96
88
  This assignment propagates the bond references to the elements, so that each element knows which bonds it is connected to.
97
- """
98
-
99
- def handle_bond_element(element: Node, bond: Bond):
100
- """Internal helper function to handle the assignment of a bond to an element.
101
-
102
- Parameters
103
- ----------
104
- element : Node
105
- Element of the bond to handle. Must be called with both `from_element` and `to_element` of the bond.
106
- bond : Bond
107
- The bond to assign to the element.
108
89
 
109
- Raises
110
- ------
111
- ValueError
112
- If the element is a junction and it is attemped to add a second strong bond.
113
- """
90
+ Iterates over each bond and calls _handle_element() for both the from_element and to_element
91
+ """
114
92
 
93
+ # Reset element bond references so that repeated calls are safe
94
+ for el in self.elements:
95
+ if isinstance(el, ElementOnePort):
96
+ el.bond = None
97
+ elif isinstance(el, ElementTwoPort):
98
+ el.bond1 = None
99
+ el.bond2 = None
100
+ elif isinstance(el, Junction):
101
+ el.bonds = []
102
+ el.strong_bond = None
103
+
104
+
105
+ def _handle_element(element: Node, bond: Bond) -> None:
106
+ """Assign bond to element according to the element's type."""
115
107
  if isinstance(element, ElementOnePort):
116
108
  element.bond = bond
117
109
 
118
110
  elif isinstance(element, ElementTwoPort):
119
- if bond.to_element == element:
111
+ # bond1 = bond INTO the element, bond2 = bond FROM the element
112
+ if bond.to_element is element:
120
113
  element.bond1 = bond
121
- elif bond.from_element == element:
114
+ elif bond.from_element is element:
122
115
  element.bond2 = bond
116
+ else:
117
+ raise ValueError(f"Bond {bond} is not connected to ElementTwoPort {element}. You have called this function incorrectly.")
123
118
 
124
119
  elif isinstance(element, Junction):
125
- element.bonds.append(bond)
126
-
127
- if isinstance(element, OneJunction):
128
- if (bond.from_element == element and bond.causality == Causality.EFFORT_OUT) or (bond.to_element == element and bond.causality == Causality.FLOW_OUT):
129
- # bond is strong bond for one junction
130
- if element.strong_bond is None:
131
- element.strong_bond = bond
132
- print(f"Assigned strong bond {bond} to OneJunction {element}.")
133
- else:
134
- raise ValueError(f"OneJunction {element} already has a strong bond: {element.strong_bond}. Cannot assign {bond}.")
135
-
136
- elif isinstance(element, ZeroJunction):
137
- if (bond.from_element == element and bond.causality == Causality.FLOW_OUT) or (bond.to_element == element and bond.causality == Causality.EFFORT_OUT):
138
- # bond is strong bond for zero junction
139
- if element.strong_bond is None:
140
- element.strong_bond = bond
141
- print(f"Assigned strong bond {bond} to ZeroJunction {element}.")
142
- else:
143
- raise ValueError(f"ZeroJunction {element} already has a strong bond: {element.strong_bond}. Cannot assign {bond}.")
144
-
145
- # Call helper function for both elements of each bond
120
+ if bond not in element.bonds:
121
+ element.bonds.append(bond)
122
+
123
+ if self._is_strong_for(bond, element):
124
+ if element.strong_bond is None:
125
+ element.strong_bond = bond
126
+ else:
127
+ raise ValueError(
128
+ f"{type(element).__name__} {element} already has a strong bond: "
129
+ f"{element.strong_bond}. Cannot assign {bond}."
130
+ )
131
+
146
132
  for bond in self.bonds:
147
- handle_bond_element(bond.from_element, bond)
148
- handle_bond_element(bond.to_element, bond)
133
+ _handle_element(bond.from_element, bond)
134
+ _handle_element(bond.to_element, bond)
149
135
 
150
136
  def __handle_equations(self) -> None:
151
137
  """Accumulates the equations from all elements and junctions in the bond graph.
@@ -173,7 +159,7 @@ class BondGraph:
173
159
  bond1 = element.bond1
174
160
  bond2 = element.bond2
175
161
  if bond1 is None or bond2 is None:
176
- raise ValueError(f"Element {element} has no connected bonds.")
162
+ raise ValueError(f"Element {element} is not fully connected with bonds.")
177
163
 
178
164
  # Add equations from the element to the bond graph
179
165
  self.equations.extend(element.equations)
@@ -192,6 +178,10 @@ class BondGraph:
192
178
  The keys include the time derivatives of the state variables and the efforts and flows of all bonds.
193
179
  """
194
180
 
181
+ # Auto-assign causality if any bonds lack it
182
+ if any(b.causality is None for b in self.bonds):
183
+ self.assign_causality()
184
+
195
185
  self.__handle_bonds()
196
186
  self.__handle_equations()
197
187
  state_derivatives = [sp.Derivative(var, "t") for var in self.state_vars]
@@ -303,7 +293,7 @@ class BondGraph:
303
293
  self,
304
294
  node_a: Node,
305
295
  node_b: Node,
306
- causality: Causality = Causality.EFFORT_OUT,
296
+ causality: Causality | None = None,
307
297
  ) -> Bond:
308
298
  """Connect two nodes by adding a bond between them.
309
299
 
@@ -331,9 +321,11 @@ class BondGraph:
331
321
  Source — becomes ``from_element`` of the new bond.
332
322
  node_b : Node
333
323
  Destination — becomes ``to_element`` of the new bond.
334
- causality : Causality, optional
335
- Causality of the connecting bond. Defaults to
336
- ``Causality.EFFORT_OUT``.
324
+ causality : Causality | None, optional
325
+ Causality of the connecting bond. Defaults to ``None``,
326
+ meaning causality will be assigned later by
327
+ :meth:`assign_causality` (SCAP). Pass a ``Causality``
328
+ value explicitly to override automatic assignment.
337
329
 
338
330
  Returns
339
331
  -------
@@ -362,6 +354,241 @@ class BondGraph:
362
354
  self.add_bond(bond)
363
355
  return bond
364
356
 
357
+ # --- shared helpers (used by both assign_causality and __handle_bonds) ---
358
+ def _elements_of_type(self, *types: type) -> list[Node]:
359
+ """Yield all elements that are instances of types."""
360
+ return [el for el in self.elements if isinstance(el, types)]
361
+
362
+ @staticmethod
363
+ def _is_strong_for(bond: Bond, junc: Junction) -> bool:
364
+ """Return True if bond is the strong bond at junc."""
365
+ if bond.causality is None:
366
+ return False
367
+
368
+ if isinstance(junc, OneJunction):
369
+ return (
370
+ (bond.from_element is junc and bond.causality == Causality.EFFORT_OUT)
371
+ or (bond.to_element is junc and bond.causality == Causality.FLOW_OUT)
372
+ )
373
+ elif isinstance(junc, ZeroJunction):
374
+ return (
375
+ (bond.from_element is junc and bond.causality == Causality.FLOW_OUT)
376
+ or (bond.to_element is junc and bond.causality == Causality.EFFORT_OUT)
377
+ )
378
+ return False
379
+
380
+ @staticmethod
381
+ def _make_strong(bond: Bond, junc: Junction) -> Causality:
382
+ """Return the causality that makes bond the strong bond at junc."""
383
+ junc_is_from = bond.from_element is junc
384
+
385
+ if isinstance(junc, OneJunction):
386
+ return Causality.EFFORT_OUT if junc_is_from else Causality.FLOW_OUT
387
+ else: # ZeroJunction
388
+ return Causality.FLOW_OUT if junc_is_from else Causality.EFFORT_OUT
389
+
390
+ @staticmethod
391
+ def _make_weak(bond: Bond, junc: Junction) -> Causality:
392
+ """Return the causality that makes bond a weak bond at junc.
393
+
394
+ This is the inverse of :meth:`_make_strong`.
395
+ """
396
+ # inverse mapping of strong to weak causality
397
+ strong_to_weak = {
398
+ Causality.EFFORT_OUT: Causality.FLOW_OUT,
399
+ Causality.FLOW_OUT: Causality.EFFORT_OUT,
400
+ }
401
+
402
+ strong = BondGraph._make_strong(bond, junc)
403
+
404
+ return strong_to_weak[strong]
405
+
406
+ def assign_causality(self) -> None:
407
+ """Run the Sequential Causality Assignment Procedure (SCAP).
408
+
409
+ Assigns causality to every bond that currently has
410
+ ``causality = None``. Bonds whose causality was set explicitly
411
+ (manually or via ``add_bond`` / ``connect``) are respected as
412
+ fixed constraints.
413
+
414
+ The algorithm proceeds in priority order:
415
+
416
+ 1. Sources: fixed causality (Se outputs effort, Sf outputs flow).
417
+ 2. Storage elements: preferred integral causality (C outputs effort, I outputs flow).
418
+ 3. Remaining bonds: assigned by propagation through junctions and two-port elements.
419
+
420
+ After each assignment the consequences are propagated through connected junctions
421
+ (exactly one strong bond each) and two-port elements
422
+ (transformer: same causality on both bonds; gyrator: opposite causality).
423
+
424
+ Raises
425
+ ------
426
+ DerivativeCausalityError
427
+ If a storage element cannot receive its preferred integral causality.
428
+ This indicates the system contains algebraic constraints (DAE instead of ODE) which are not supported.
429
+ CausalityError
430
+ If causality cannot be fully resolved (should not happen for a well-formed bond graph).
431
+ """
432
+
433
+ # Populate bond references on elements so we can use access them when assigning causality.
434
+ self.__handle_bonds()
435
+
436
+ # Track which bonds still need assignment
437
+ unassigned: set[Bond] = {b for b in self.bonds if b.causality is None}
438
+
439
+ if not unassigned:
440
+ return
441
+
442
+ def _assign(bond: Bond, causality: Causality) -> None:
443
+ """Assign causality to a bond and remove it from the unassigned set."""
444
+ bond.causality = causality
445
+ unassigned.discard(bond)
446
+
447
+ def _propagate() -> None:
448
+ """Propagate causality through junctions and two-port elements
449
+ until no further assignments can be made."""
450
+ if not unassigned:
451
+ return
452
+
453
+ changed = True
454
+ while changed:
455
+ changed = False
456
+
457
+ # Junctions: if any strong bond exists, all unassigned bonds must be weak
458
+ for junc in self._elements_of_type(Junction):
459
+ assigned_bonds = [b for b in junc.bonds if b.causality is not None]
460
+ unassigned_here = [b for b in junc.bonds if b.causality is None]
461
+ if not unassigned_here:
462
+ continue
463
+
464
+ has_strong = any(self._is_strong_for(b, junc) for b in assigned_bonds)
465
+
466
+ if has_strong:
467
+ for b in unassigned_here:
468
+ _assign(b, self._make_weak(b, junc))
469
+ changed = True
470
+ elif len(unassigned_here) == 1:
471
+ _assign(unassigned_here[0], self._make_strong(unassigned_here[0], junc))
472
+ changed = True
473
+
474
+ # Two-port elements: if one bond has causality, the other must be assigned accordingly
475
+ for tp_elem in self._elements_of_type(ElementTwoPort):
476
+ b1, b2 = tp_elem.bond1, tp_elem.bond2
477
+ if b1 is None or b2 is None:
478
+ continue
479
+
480
+ src, dst = None, None
481
+ if b1.causality is not None and b2.causality is None:
482
+ src, dst = b1, b2
483
+ elif b2.causality is not None and b1.causality is None:
484
+ src, dst = b2, b1
485
+ else:
486
+ continue
487
+
488
+ if isinstance(tp_elem, Transformer):
489
+ _assign(dst, src.causality)
490
+ else: # Gyrator — opposite causality
491
+ opp = Causality.FLOW_OUT if src.causality == Causality.EFFORT_OUT else Causality.EFFORT_OUT
492
+ _assign(dst, opp)
493
+ changed = True
494
+
495
+ if not unassigned:
496
+ return
497
+
498
+ def _desired_causality(element: ElementOnePort, bond: Bond) -> Causality:
499
+ """Return the causality the element wants (from from_element perspective)."""
500
+ el_is_from = bond.from_element is element
501
+ if isinstance(element, (SourceEffort, Capacitor)):
502
+ return Causality.EFFORT_OUT if el_is_from else Causality.FLOW_OUT
503
+ elif isinstance(element, (SourceFlow, Inductor)):
504
+ return Causality.FLOW_OUT if el_is_from else Causality.EFFORT_OUT
505
+ return None
506
+
507
+ def _would_conflict_with_junction(bond: Bond, causality: Causality) -> bool:
508
+ """Check if assigning *causality* to *bond* would create a
509
+ second strong bond at any connected junction."""
510
+ for el in bond.elements:
511
+ if not isinstance(el, Junction):
512
+ continue
513
+ # Temporarily check what this would mean
514
+ old = bond.causality
515
+ bond.causality = causality
516
+ is_strong = self._is_strong_for(bond, el)
517
+ bond.causality = old
518
+ if is_strong:
519
+ other_bonds = [b for b in el.bonds if b is not bond and b.causality is not None]
520
+ if any(self._is_strong_for(ob, el) for ob in other_bonds):
521
+ return True
522
+ return False
523
+
524
+ # --- Phase 1: Sources (fixed causality) -----------------------------
525
+ for elem in self._elements_of_type(SourceEffort, SourceFlow):
526
+ if elem.bond.causality is None:
527
+ _assign(elem.bond, _desired_causality(elem, elem.bond))
528
+ _propagate()
529
+
530
+ # --- Phase 2: Storage elements (integral causality) -----------------
531
+ for elem in self._elements_of_type(Capacitor, Inductor):
532
+ if elem.bond.causality is not None:
533
+ continue
534
+ desired = _desired_causality(elem, elem.bond)
535
+ if not _would_conflict_with_junction(elem.bond, desired):
536
+ _assign(elem.bond, desired)
537
+ else:
538
+ raise DerivativeCausalityError(
539
+ f"Storage element '{elem.name}' ({type(elem).__name__}) "
540
+ f"cannot receive its preferred integral causality "
541
+ f"because a connected junction already has a strong bond. "
542
+ f"This forces derivative causality, turning the system "
543
+ f"into a DAE (differential-algebraic equation) which is "
544
+ f"not supported.\n"
545
+ f"Suggestion: insert a resistive element (R) between "
546
+ f"conflicting storage elements, or restructure the model."
547
+ )
548
+ _propagate()
549
+
550
+ # --- Phase 3: Resistors and remaining bonds -------------------------
551
+ for elem in self._elements_of_type(Resistor):
552
+ if elem.bond.causality is not None:
553
+ continue
554
+ bond = elem.bond
555
+ # Determine from the connected junction what this bond needs
556
+ other_el = bond.to_element if bond.from_element is elem else bond.from_element
557
+ if isinstance(other_el, Junction):
558
+ assigned_bonds = [b for b in other_el.bonds if b.causality is not None]
559
+ has_strong = any(self._is_strong_for(b, other_el) for b in assigned_bonds)
560
+ if has_strong:
561
+ _assign(bond, self._make_weak(bond, other_el))
562
+ elif len([b for b in other_el.bonds if b.causality is None]) == 1:
563
+ # if this is the only unassigned bond at the junction, it must be strong
564
+ _assign(bond, self._make_strong(bond, other_el))
565
+ _propagate()
566
+
567
+ # --- Validate: two-port causality consistency -----------------------
568
+ for tp_elem in self._elements_of_type(ElementTwoPort):
569
+ b1, b2 = tp_elem.bond1, tp_elem.bond2
570
+ if b1 is None or b2 is None:
571
+ continue
572
+ if isinstance(tp_elem, Transformer) and b1.causality != b2.causality:
573
+ raise CausalityError(
574
+ f"Transformer '{tp_elem.name}' requires both bonds to have the "
575
+ f"same causality, but bond1={b1.causality} and bond2={b2.causality}."
576
+ )
577
+ if isinstance(tp_elem, Gyrator) and b1.causality == b2.causality:
578
+ raise CausalityError(
579
+ f"Gyrator '{tp_elem.name}' requires both bonds to have different "
580
+ f"causality, but both are {b1.causality}."
581
+ )
582
+
583
+ # --- Validate: unassigned bonds -------------------------------------
584
+ if unassigned:
585
+ descriptions = [f" Bond({b.from_element.name} -> {b.to_element.name})" for b in unassigned]
586
+ raise CausalityError(
587
+ f"SCAP could not assign causality to {len(unassigned)} bond(s):\n"
588
+ + "\n".join(descriptions)
589
+ + "\nCheck that the bond graph is fully connected and well-formed."
590
+ )
591
+
365
592
  def plot(self, layout: Callable[[nx.Graph, ...], dict] = nx.spectral_layout, **kwargs) -> tuple[plt.Figure, plt.Axes]:
366
593
  """Plots the bond graph as a `networkx` graph.
367
594
 
@@ -432,7 +659,7 @@ class BondGraph:
432
659
  for edge_idx, (u, v, data) in enumerate(G.edges(data=True)):
433
660
  x1, y1 = pos[u]
434
661
  x2, y2 = pos[v]
435
- # Vary the parameter t ∈ [0.35, 0.65] per edge
662
+ # Vary the parameter t \in [0.35, 0.65] per edge
436
663
  t = 0.35 + 0.3 * ((edge_idx * 7 + 3) % 11) / 10.0
437
664
  lx = x1 + t * (x2 - x1)
438
665
  ly = y1 + t * (y2 - y1)
@@ -469,7 +696,7 @@ class BondGraph:
469
696
  If the 'at' parameter is not "head" or "tail".
470
697
  """
471
698
 
472
- # --- Convert node size (points^2) to radius in pixels ---
699
+ # Convert node size (points^2, i.e. area) to radius in pixels
473
700
  radius_points = np.sqrt(node_size / np.pi)
474
701
  radius_pixels = radius_points * ax.figure.dpi / 72.0 # 1 point = 1/72 inch
475
702
  offset = radius_pixels + padding # + padding (in px) so stroke sits outside node
@@ -515,6 +742,8 @@ class BondGraph:
515
742
  _stroke_specs.append((pos[u], pos[v], "head", -2))
516
743
  elif causality is Causality.FLOW_OUT:
517
744
  _stroke_specs.append((pos[u], pos[v], "tail", -2))
745
+ elif causality is None:
746
+ pass # No causality assigned yet — skip causal stroke
518
747
  else:
519
748
  raise ValueError(f"Edge {u}->{v} has no valid causality: {causality} --> this should never happen!")
520
749
 
@@ -17,6 +17,20 @@ class Causality(Enum):
17
17
  FLOW_OUT = "flow_out"
18
18
 
19
19
 
20
+ class CausalityError(Exception):
21
+ """Raised when automatic causality assignment (SCAP) fails."""
22
+ pass
23
+
24
+
25
+ class DerivativeCausalityError(CausalityError):
26
+ """Raised when a storage element would require derivative causality.
27
+
28
+ Derivative causality turns the system into a DAE (differential-algebraic equation) which cannot directly be transformed into state-space representation.
29
+ Consider adding a small resistance (or the like) between conflicting storage elements or restructuring the model.
30
+ """
31
+ pass
32
+
33
+
20
34
  class StatefulElement(ABC):
21
35
  """Base class for all stateful elements (capacitor, inductor) in the bond graph.
22
36
  Requires implementation of a `state_var` property that returns the symbolic state variable associated with the element.
@@ -51,7 +65,15 @@ class Bond:
51
65
 
52
66
  _counter = 0 # Global fallback counter; prefer BondGraph-scoped numbering
53
67
 
54
- def __init__(self, from_element: Node, to_element: Node, causality: str | Causality, num: int | None = None, instance_name: str = "", is_prefix: bool = True):
68
+ def __init__(
69
+ self,
70
+ from_element: Node,
71
+ to_element: Node,
72
+ causality: str | Causality | None = None,
73
+ num: int | None = None,
74
+ instance_name: str = "",
75
+ is_prefix: bool = True,
76
+ ):
55
77
  """Create a bond between two elements with specified causality.
56
78
  The positive direction of this power bond is from `from_element` to `to_element`.
57
79
  Efforts and flows are represented by symbolic `sympy.Symbol`s that are strictly real-valued.
@@ -62,7 +84,7 @@ class Bond:
62
84
  The element where the bond originates.
63
85
  to_element : Node
64
86
  The element where the bond terminates.
65
- causality : str | Causality
87
+ causality : str | Causality | None, optional
66
88
  The causality of the bond, either `effort_out` or `flow_out`.
67
89
  If a string is provided, it is converted to the corresponding `Causality` enum.
68
90
  This definition is always from the perspective of the `from_element`.
@@ -70,6 +92,7 @@ class Bond:
70
92
  `OneJunction` imposes effort on the `Inductor`, meaning it has an equivalent `effort_in` causality.
71
93
  Likewise a `Bond(OneJunction(...), Capacitor(...), "flow_out")` means that the `OneJunction` imposes
72
94
  flow on the `Capacitor`, meaning it has an equivalent `flow_in`/`effort_out` causality.
95
+ If None, the causality must be assigned later by calling (manually or automatically) the bond graph's causality assignment algorithm.
73
96
  num : int | None, optional
74
97
  Explicit bond number. If None, the global fallback counter is used.
75
98
  When bonds are added to a BondGraph, the graph manages numbering.
@@ -89,10 +112,11 @@ class Bond:
89
112
  self.to_element = to_element
90
113
 
91
114
  if isinstance(causality, str):
92
- causality = Causality(causality.lower()) # Convert string to Causality enum, case insensitive
115
+ causality = Causality(causality.lower())
116
+ # Convert string to Causality enum, case insensitive
93
117
  # --> automatically raises ValueError if string is not valid
94
118
 
95
- self.causality: Causality = causality
119
+ self.causality: Causality | None = causality
96
120
 
97
121
  if num is None:
98
122
  self.num = Bond._counter
@@ -114,7 +138,12 @@ class Bond:
114
138
  """tuple[Node, Node]: The two elements connected by the bond. First element is `from_element`, second is `to_element`."""
115
139
  return (self.from_element, self.to_element)
116
140
 
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]:
141
+ def rename_symbols(
142
+ self,
143
+ new_num: int | None = None,
144
+ new_instance_name: str | None = None,
145
+ is_prefix: bool = True,
146
+ ) -> dict[sp.Symbol, sp.Symbol]:
118
147
  """Rename the effort/flow symbols of this bond and return the substitution map.
119
148
  This is used during sub-model merging to avoid symbol collisions.
120
149
 
@@ -140,13 +169,12 @@ class Bond:
140
169
  if new_instance_name is not None:
141
170
  self.instance_name = new_instance_name
142
171
 
143
-
144
172
  if is_prefix:
145
173
  padded_name = "_" + self.instance_name + "_" if self.instance_name != "" else "_"
146
174
  self.effort = sp.Symbol(f"e{padded_name}{self.num}", real=True)
147
175
  self.flow = sp.Symbol(f"f{padded_name}{self.num}", real=True)
148
176
  else:
149
- padded_name = self.instance_name if self.instance_name != "" else "" # conditional can be skipped
177
+ padded_name = self.instance_name if self.instance_name != "" else "" # conditional can be skipped
150
178
  self.effort = sp.Symbol(f"e_{self.num}{padded_name}", real=True)
151
179
  self.flow = sp.Symbol(f"f_{self.num}{padded_name}", real=True)
152
180
 
@@ -198,7 +226,9 @@ class ElementOnePort(Node, ABC):
198
226
  """
199
227
 
200
228
  super().__init__(name)
201
- self.value = sp.Symbol(value, real=True, positive=True) # Ensure value is a positive real number
229
+ self.value = sp.Symbol(
230
+ value, real=True, positive=True
231
+ ) # Ensure value is a positive real number
202
232
  self.bond: Bond = None # bond that connects this element to a bond graph
203
233
 
204
234
  @property
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyBondGraph
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -36,9 +36,15 @@ Bond graphs provide a **domain-independent modeling framework** for physical sys
36
36
  # Features
37
37
 
38
38
  * Programmatic construction of **bond graph models**
39
+ * **Automatic causality assignment** via SCAP (Sequential Causality Assignment Procedure), with optional manual override or mixed mode
39
40
  * Automatic **symbolic equation derivation** using SymPy
40
- * Conversion of models to **state-space systems**
41
- * Example models for electrical and mechanical systems
41
+ * Conversion of models to **linear state-space systems** ($\dot{x} = Ax + Bu$, $y = Cx + Du$)
42
+ * **Composable sub-models** via `SubBondGraph` with deep-copy namespace isolation
43
+ * **Two-port elements**: Transformer and Gyrator with automatic causality propagation
44
+ * **Sensor elements**: `IntegratedEffortSensor` and `IntegratedFlowSensor` for measuring integrated generalized variables (e.g. position from velocity)
45
+ * **Domain-neutral aliases**: `Compliance` = `Capacitor`, `Inertance` = `Inductor`, `Resistance` = `Resistor`
46
+ * Example models for electrical and electromechanical systems
47
+ * Integration with **python-control** for numerical simulation (step response, Bode plots, etc.)
42
48
 
43
49
  ---
44
50
 
@@ -81,12 +87,12 @@ Optional dependencies are used for experimental visualization tools.
81
87
  ---
82
88
 
83
89
  # Basic Usage
84
- A bond graph model is constructed by creating elements and connecting them via bonds.
90
+ A bond graph model is constructed by creating elements and connecting them via the `connect()` convenience method, which creates bonds and adds them to the graph in one step.
85
91
 
86
- Simple RC-Filter circuit:
92
+ ## RC-Filter with automatic causality (SCAP)
87
93
 
88
94
  ```python
89
- from pyBondGraph import BondGraph, SourceEffort, Resistor, Capacitor, OneJunction, Bond, Causality
95
+ from pyBondGraph import BondGraph, SourceEffort, Resistor, Capacitor, OneJunction
90
96
 
91
97
  bg = BondGraph()
92
98
 
@@ -96,19 +102,20 @@ resistor = Resistor("R", "R")
96
102
  capacitor = Capacitor("C", "C")
97
103
  series_junction = OneJunction("J1")
98
104
 
99
- # connect elements
100
- # causalities need to be assigned manually
101
- bg.add_bond(Bond(voltage_source, series_junction, Causality.EFFORT_OUT))
102
- bg.add_bond(Bond(series_junction, resistor, Causality.EFFORT_OUT))
103
- bg.add_bond(Bond(series_junction, capacitor, Causality.FLOW_OUT))
105
+ # connect elements --> causality is assigned automatically by SCAP
106
+ bg.connect(voltage_source, series_junction)
107
+ bg.connect(series_junction, resistor)
108
+ bg.connect(series_junction, capacitor)
104
109
 
105
110
  # plot the resulting BondGraph
106
111
  bg.plot()
107
112
 
108
113
  # derive system equations in linear state space form
109
- A, B, C, D, x, n_states, n_inputs, n_outputs = bond_graph.get_state_space()
114
+ A, B, C, D, x, n_states, n_inputs, n_outputs = bg.get_state_space()
110
115
  ```
111
116
 
117
+ Causality can also be assigned **manually** by passing a `Causality` value to `connect()`, or in **mixed mode** where some bonds are fixed and SCAP resolves the rest.
118
+
112
119
  The library automatically derives the **symbolic system equations** describing the dynamics of the model.
113
120
 
114
121
  ---
@@ -126,6 +133,12 @@ Bond graphs represent **power exchange between system components**, where power
126
133
  | Se | Effort source |
127
134
  | Sf | Flow source |
128
135
 
136
+ ## Two-Port Elements
137
+ | Element | Meaning |
138
+ |-------------|------------------------------------------------------------|
139
+ | TF | Transformer — same causality on both bonds |
140
+ | GY | Gyrator — opposite causality on both bonds |
141
+
129
142
  ## Junctions
130
143
  | Junction | Meaning |
131
144
  |----------|---------------|
@@ -139,11 +152,9 @@ Bond graphs represent **power exchange between system components**, where power
139
152
  | IntegratedFlowSensor | Measures integral of the flow at its bond |
140
153
 
141
154
  In mechanical bond graph models:
142
- * **flow** corresponds to **velocity**
155
+ * **flow** corresponds to **velocity**, i.e. an *IntegratedFlowSensor* can be used to compute **position**.
143
156
  * **effort** corresponds to **force**
144
157
 
145
- An **integrated flow sensor** can therefore be used to compute **position**:
146
-
147
158
  ---
148
159
 
149
160
  # Example Systems
@@ -153,7 +164,7 @@ The repository contains example models illustrating typical applications of bond
153
164
  Demonstrates modeling of an electrical circuit using bond graph elements.
154
165
 
155
166
  ### DC Motor
156
- A multi-domain electromechanical system coupling electrical and mechanical dynamics.
167
+ A multi-domain electromechanical system coupling electrical and mechanical dynamics. Also demonstrates integration with the `python-control` package for numerical simulation (step response).
157
168
 
158
169
  ### Transformer
159
170
  Example of energy transformation between two ports.
@@ -163,6 +174,18 @@ Classical mass-spring-damper system with two degrees of freedom.
163
174
 
164
175
  ---
165
176
 
177
+ # Causality Assignment
178
+
179
+ pyBondGraph supports three modes for assigning causality:
180
+
181
+ 1. **Automatic (SCAP)** — omit causality in `connect()` calls; `assign_causality()` is called automatically when solving. The Sequential Causality Assignment Procedure assigns causality in priority order: sources, storage elements (integral causality), resistors, then propagation through junctions and two-port elements.
182
+ 2. **Manual** — pass `Causality.EFFORT_OUT` or `Causality.FLOW_OUT` explicitly to each `connect()` call.
183
+ 3. **Mixed** — fix causality on some bonds, let SCAP resolve the rest.
184
+
185
+ If a storage element cannot receive integral causality (which would imply a DAE rather than an ODE), a `DerivativeCausalityError` is raised with a clear diagnostic message.
186
+
187
+ ---
188
+
166
189
  # Typical Applications
167
190
  Bond graph modeling is particularly useful for:
168
191
 
@@ -171,3 +194,11 @@ Bond graph modeling is particularly useful for:
171
194
  * multi-domain energy systems
172
195
  * control system modeling
173
196
  * teaching system dynamics
197
+
198
+ ---
199
+
200
+ # Planned Features
201
+
202
+ * **FMU Export**: export bond graph models as Functional Mock-up Units (FMI standard) for interoperability with Simulink, Dymola, OpenModelica, and other FMI-compliant tools
203
+ * **Nonlinear element support**: general nonlinear constitutive laws with Jacobian linearization
204
+ * **Convenience bridge to python-control**: `to_control_ss(params)` method wrapping the existing manual pattern
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyBondGraph"
3
- version = "0.2.0"
3
+ version = "0.3.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