Bondgraph causality inference
This commit is contained in:
14
src/bedit_core/bondgraph/__init__.py
Normal file
14
src/bedit_core/bondgraph/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Bond-graph flattening and network representations."""
|
||||||
|
|
||||||
|
from .flatten import FlattenError, flatten_bondgraph
|
||||||
|
from .network import BondGraphNetwork, NetworkBond, NetworkPort
|
||||||
|
from .causality import causality_inference
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BondGraphNetwork",
|
||||||
|
"FlattenError",
|
||||||
|
"NetworkBond",
|
||||||
|
"NetworkPort",
|
||||||
|
"flatten_bondgraph",
|
||||||
|
"causality_inference"
|
||||||
|
]
|
||||||
206
src/bedit_core/bondgraph/causality.py
Normal file
206
src/bedit_core/bondgraph/causality.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
from bedit_core.models import Component, BondCausality, BondPort, PortCausality, BondConnection
|
||||||
|
from .network import BondGraphNetwork, NetworkPort, NetworkBond
|
||||||
|
from .flatten import flatten_bondgraph
|
||||||
|
|
||||||
|
class InferenceError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def causality_inference(root: Component) -> Component:
|
||||||
|
"""Performs causality inference on a component and its subcomponents and returns with a constrained one"""
|
||||||
|
engine = _CausalityEngine(root)
|
||||||
|
return engine.inference()
|
||||||
|
|
||||||
|
class _CausalityEngine():
|
||||||
|
def __init__(self, root: Component) -> None:
|
||||||
|
self._root = root
|
||||||
|
self._network = flatten_bondgraph(root)
|
||||||
|
|
||||||
|
def inference(self):
|
||||||
|
self.clear_causalities()
|
||||||
|
|
||||||
|
# Phase 1: fixed causalities
|
||||||
|
for port in self._network.ports:
|
||||||
|
if not isinstance(port.port, BondPort):
|
||||||
|
continue
|
||||||
|
if port.port.causality_preference == PortCausality.FIXED_EFFORT_OUT:
|
||||||
|
self.propagate_from_port(port, BondCausality.EFFORT_OUT)
|
||||||
|
elif port.port.causality_preference == PortCausality.FIXED_FLOW_OUT:
|
||||||
|
self.propagate_from_port(port, BondCausality.FLOW_OUT)
|
||||||
|
|
||||||
|
# Phase 2: preferred causalities
|
||||||
|
for port in self._network.ports:
|
||||||
|
if not isinstance(port.port, BondPort):
|
||||||
|
continue
|
||||||
|
if self.is_fully_assigned(port):
|
||||||
|
continue
|
||||||
|
if port.port.causality_preference == PortCausality.PREFERRED_EFFORT_OUT:
|
||||||
|
self.propagate_from_port(port, BondCausality.EFFORT_OUT)
|
||||||
|
elif port.port.causality_preference == PortCausality.PREFERRED_FLOW_OUT:
|
||||||
|
self.propagate_from_port(port, BondCausality.FLOW_OUT)
|
||||||
|
|
||||||
|
# Phase 2: soft choiced causalities
|
||||||
|
# May need several passed when a junction port has multiple resolved bonds
|
||||||
|
# Stop as soon as a pass makes no progress
|
||||||
|
while True:
|
||||||
|
unresolved_before = sum(
|
||||||
|
isinstance(con.connection, BondConnection) and con.connection.causality == BondCausality.NONE
|
||||||
|
for con in self._network.bonds
|
||||||
|
)
|
||||||
|
for port in self._network.ports:
|
||||||
|
if not isinstance(port.port, BondPort):
|
||||||
|
continue
|
||||||
|
if self.is_fully_assigned(port):
|
||||||
|
continue
|
||||||
|
if port.port.causality_preference == PortCausality.LIKES_EFFORT_OUT:
|
||||||
|
self.propagate_from_port(port, BondCausality.EFFORT_OUT)
|
||||||
|
elif port.port.causality_preference == PortCausality.LIKES_FLOW_OUT:
|
||||||
|
self.propagate_from_port(port, BondCausality.FLOW_OUT)
|
||||||
|
elif port.port.causality_preference == PortCausality.SINGLE_EFFORT_IN:
|
||||||
|
self.evaluate_junction_constraints(port)
|
||||||
|
if not self.is_fully_assigned(port):
|
||||||
|
self.propagate_from_port(port, BondCausality.FLOW_OUT)
|
||||||
|
elif port.port.causality_preference == PortCausality.SINGLE_FLOW_IN:
|
||||||
|
self.evaluate_junction_constraints(port)
|
||||||
|
if not self.is_fully_assigned(port):
|
||||||
|
self.propagate_from_port(port, BondCausality.EFFORT_OUT)
|
||||||
|
elif port.port.causality_preference == PortCausality.INDIFFERENT:
|
||||||
|
# Force an arbirary assignment on the first unassigned bond
|
||||||
|
self.propagate_from_port(port, BondCausality.EFFORT_OUT)
|
||||||
|
unresolved_after = sum(
|
||||||
|
isinstance(con.connection, BondConnection) and con.connection.causality == BondCausality.NONE
|
||||||
|
for con in self._network.bonds
|
||||||
|
)
|
||||||
|
if unresolved_after == 0 or unresolved_after >= unresolved_before:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Last check
|
||||||
|
for con in self._network.bonds:
|
||||||
|
if con.connection.causality == BondCausality.NONE:
|
||||||
|
raise InferenceError("System under-constrained: unresolved causal loops or disconnected elements remain")
|
||||||
|
|
||||||
|
return self._root
|
||||||
|
|
||||||
|
def clear_causalities(self) -> None:
|
||||||
|
for bond in self._network.bonds:
|
||||||
|
bond.connection.causality = BondCausality.NONE
|
||||||
|
|
||||||
|
def propagate_from_port(self, port: NetworkPort, causality: BondCausality) -> None:
|
||||||
|
attached_connections = self._network.bonds_for(port)
|
||||||
|
# Get first unassigned connection on this port
|
||||||
|
target_conn = None
|
||||||
|
for conn in attached_connections:
|
||||||
|
if conn.connection.causality == BondCausality.NONE:
|
||||||
|
target_conn = conn
|
||||||
|
break
|
||||||
|
if target_conn is None:
|
||||||
|
# Nothing to resolve
|
||||||
|
return
|
||||||
|
# Update causality based on target_conns perspective
|
||||||
|
if target_conn.source.component == port.component:
|
||||||
|
target_conn.connection.causality = BondCausality.EFFORT_OUT if (causality == BondCausality.EFFORT_OUT) else BondCausality.FLOW_OUT
|
||||||
|
else:
|
||||||
|
target_conn.connection.causality = BondCausality.FLOW_OUT if (causality == BondCausality.EFFORT_OUT) else BondCausality.EFFORT_OUT
|
||||||
|
# Continue on the other side of the bond
|
||||||
|
self.propagate_to_neighbor(port.component, target_conn)
|
||||||
|
|
||||||
|
def propagate_to_neighbor(self, component: Component, connection: NetworkBond) -> None:
|
||||||
|
# Get neighor
|
||||||
|
is_source = (component == connection.source.component)
|
||||||
|
neighbor = connection.target if is_source else connection.target
|
||||||
|
# Direct the evaluation based on what type of port it is
|
||||||
|
if neighbor.port.causality_preference in [PortCausality.SINGLE_EFFORT_IN, PortCausality.SINGLE_FLOW_IN]:
|
||||||
|
self.evaluate_junction_constraints(neighbor)
|
||||||
|
else:
|
||||||
|
self.verify_component_compatibility(neighbor)
|
||||||
|
|
||||||
|
def evaluate_junction_constraints(self, port: NetworkPort) -> None:
|
||||||
|
efforts_in = 0
|
||||||
|
flows_in = 0
|
||||||
|
unassigned_conns: list[NetworkBond] = []
|
||||||
|
|
||||||
|
for con in self._network.bonds_for(port):
|
||||||
|
if con.connection.causality == BondCausality.NONE:
|
||||||
|
unassigned_conns.append(con)
|
||||||
|
continue
|
||||||
|
is_source = (con.source.component == port.component)
|
||||||
|
if (is_source and con.connection.causality == BondCausality.FLOW_OUT) or (not is_source and con.connection.causality == BondCausality.EFFORT_OUT):
|
||||||
|
efforts_in += 1
|
||||||
|
else:
|
||||||
|
flows_in += 1
|
||||||
|
|
||||||
|
# 0 junction port
|
||||||
|
if port.port.causality_preference == PortCausality.SINGLE_EFFORT_IN:
|
||||||
|
# Error check
|
||||||
|
if efforts_in > 1:
|
||||||
|
raise InferenceError(f"Critical causality conflict: multiple ports are dictating effort to 0-junction port: {port.component.name}")
|
||||||
|
|
||||||
|
# Propagation A: 1 effort is comming in so remaining connections are effort out
|
||||||
|
if efforts_in == 1 and len(unassigned_conns) > 0:
|
||||||
|
# Force effort out to the rest
|
||||||
|
for con in unassigned_conns:
|
||||||
|
desired = BondCausality.EFFORT_OUT if (con.source.component == port.component) else BondCausality.FLOW_OUT
|
||||||
|
con.connection.causality = desired
|
||||||
|
self.propagate_to_neighbor(port.component, con)
|
||||||
|
|
||||||
|
# Propagation B: no effort is coming in yet and only one remaining so must be effort in
|
||||||
|
elif efforts_in == 0 and len(unassigned_conns) == 1:
|
||||||
|
con = unassigned_conns[0]
|
||||||
|
desired = BondCausality.FLOW_OUT if (con.source.component == port.component) else BondCausality.EFFORT_OUT
|
||||||
|
con.connection.causality = desired
|
||||||
|
self.propagate_to_neighbor(port.component, con)
|
||||||
|
|
||||||
|
# 1 junction port
|
||||||
|
elif port.port.causality_preference == PortCausality.SINGLE_FLOW_IN:
|
||||||
|
# Error check
|
||||||
|
if flows_in > 1:
|
||||||
|
raise InferenceError(f"Critical causality conflict: multiple ports are dictating flow to 1-junction port: {port.component.name}")
|
||||||
|
|
||||||
|
# Propagation A: 1 flow is comming in so remaining connections are effort in
|
||||||
|
if flows_in == 1 and len(unassigned_conns) > 0:
|
||||||
|
# Force flow out to the rest
|
||||||
|
for con in unassigned_conns:
|
||||||
|
desired = BondCausality.FLOW_OUT if (con.source.component == port.component) else BondCausality.EFFORT_OUT
|
||||||
|
con.connection.causality = desired
|
||||||
|
self.propagate_to_neighbor(port.component, con)
|
||||||
|
|
||||||
|
# Propagation B: no flow is coming in yet and only one remaining so must be flow in
|
||||||
|
elif flows_in == 0 and len(unassigned_conns) == 1:
|
||||||
|
con = unassigned_conns[0]
|
||||||
|
desired = BondCausality.EFFORT_OUT if (con.source.component == port.component) else BondCausality.FLOW_OUT
|
||||||
|
con.connection.causality = desired
|
||||||
|
self.propagate_to_neighbor(port.component, con)
|
||||||
|
|
||||||
|
def verify_component_compatibility(self, port: NetworkPort) -> None:
|
||||||
|
conn = None
|
||||||
|
for c in self._network.bonds_for(port):
|
||||||
|
if c.connection.causality != BondCausality.NONE:
|
||||||
|
conn = c
|
||||||
|
break
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Figure out what causality was pushed onto this port from the outside world
|
||||||
|
state = conn.connection.causality
|
||||||
|
is_source = (conn.source.component == port.component)
|
||||||
|
actual_causality = BondCausality.NONE
|
||||||
|
if (is_source and state == BondCausality.EFFORT_OUT) or (not is_source and state == BondCausality.FLOW_OUT):
|
||||||
|
actual_causality = BondCausality.EFFORT_OUT
|
||||||
|
else:
|
||||||
|
actual_causality = BondCausality.FLOW_OUT
|
||||||
|
|
||||||
|
if port.port.causality_preference == PortCausality.FIXED_EFFORT_OUT and actual_causality == BondCausality.FLOW_OUT:
|
||||||
|
raise InferenceError(f"Critical source conflict: Fixed effort source {port.component.name} forced into an input state")
|
||||||
|
if port.port.causality_preference == PortCausality.FIXED_FLOW_OUT and actual_causality == BondCausality.EFFORT_OUT:
|
||||||
|
raise InferenceError(f"Critical source conflict: Fixed flow source {port.component.name} forced into an input state")
|
||||||
|
|
||||||
|
# Non-preferenced causality checks
|
||||||
|
if port.port.causality_preference == PortCausality.PREFERRED_EFFORT_OUT and actual_causality == BondCausality.FLOW_OUT:
|
||||||
|
conn.connection.undesired = True
|
||||||
|
if port.port.causality_preference == PortCausality.PREFERRED_FLOW_OUT and actual_causality == BondCausality.EFFORT_OUT:
|
||||||
|
conn.connection.undesired = True
|
||||||
|
|
||||||
|
def is_fully_assigned(self, port: NetworkPort) -> bool:
|
||||||
|
for con in self._network.bonds_for(port):
|
||||||
|
if con.connection.causality == BondCausality.NONE:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
119
src/bedit_core/bondgraph/flatten.py
Normal file
119
src/bedit_core/bondgraph/flatten.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Flatten hierarchical graph implementations into one bond-graph network."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from bedit_core.models import (
|
||||||
|
BondConnection,
|
||||||
|
BondPort,
|
||||||
|
Component,
|
||||||
|
ComponentID,
|
||||||
|
GraphImplementation,
|
||||||
|
PortID,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .network import BondGraphNetwork, ComponentPath, NetworkBond, NetworkPort
|
||||||
|
|
||||||
|
|
||||||
|
class FlattenError(ValueError):
|
||||||
|
"""Raised when a hierarchical bond graph cannot be resolved."""
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_bondgraph(root: Component) -> BondGraphNetwork:
|
||||||
|
"""Return a flat network containing every nested bond-graph element.
|
||||||
|
|
||||||
|
Graph interface ports are represented once and reused by bonds on either
|
||||||
|
side of the component boundary, making the full hierarchy connected.
|
||||||
|
"""
|
||||||
|
return _NetworkBuilder(root).build()
|
||||||
|
|
||||||
|
|
||||||
|
class _NetworkBuilder:
|
||||||
|
def __init__(self, root: Component) -> None:
|
||||||
|
self._root = root
|
||||||
|
self._network = BondGraphNetwork(root=root)
|
||||||
|
self._ports: dict[tuple[ComponentPath, PortID], NetworkPort] = {}
|
||||||
|
|
||||||
|
def build(self) -> BondGraphNetwork:
|
||||||
|
"""Traverse the hierarchy and return the completed network."""
|
||||||
|
self._visit_component(self._root, ())
|
||||||
|
return self._network
|
||||||
|
|
||||||
|
def _visit_component(
|
||||||
|
self,
|
||||||
|
component: Component,
|
||||||
|
path: ComponentPath,
|
||||||
|
) -> None:
|
||||||
|
self._network.components.append(component)
|
||||||
|
self._register_ports(component, path)
|
||||||
|
|
||||||
|
if not isinstance(component.implementation, GraphImplementation):
|
||||||
|
return
|
||||||
|
|
||||||
|
graph = component.implementation.graph
|
||||||
|
for component_id, child in graph.components.items():
|
||||||
|
self._visit_component(child, path + (component_id,))
|
||||||
|
|
||||||
|
for connection_id, connection in graph.connections.items():
|
||||||
|
if not isinstance(connection, BondConnection):
|
||||||
|
continue
|
||||||
|
self._network.bonds.append(
|
||||||
|
NetworkBond(
|
||||||
|
component_path=path,
|
||||||
|
connection_id=connection_id,
|
||||||
|
connection=connection,
|
||||||
|
source=self._resolve_port(
|
||||||
|
path,
|
||||||
|
graph.components,
|
||||||
|
connection.source,
|
||||||
|
),
|
||||||
|
target=self._resolve_port(
|
||||||
|
path,
|
||||||
|
graph.components,
|
||||||
|
connection.target,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _register_ports(
|
||||||
|
self,
|
||||||
|
component: Component,
|
||||||
|
path: ComponentPath,
|
||||||
|
) -> None:
|
||||||
|
for port_id, port in component.interface.ports.items():
|
||||||
|
if not isinstance(port, BondPort):
|
||||||
|
continue
|
||||||
|
network_port = NetworkPort(path, component, port_id, port)
|
||||||
|
self._ports[(path, port_id)] = network_port
|
||||||
|
self._network.ports.append(network_port)
|
||||||
|
|
||||||
|
def _resolve_port(
|
||||||
|
self,
|
||||||
|
graph_path: ComponentPath,
|
||||||
|
children: dict[ComponentID, Component],
|
||||||
|
port_id: PortID,
|
||||||
|
) -> NetworkPort:
|
||||||
|
candidate_paths = [graph_path]
|
||||||
|
candidate_paths.extend(
|
||||||
|
graph_path + (component_id,)
|
||||||
|
for component_id in children
|
||||||
|
)
|
||||||
|
matches = [
|
||||||
|
self._ports[(path, port_id)]
|
||||||
|
for path in candidate_paths
|
||||||
|
if (path, port_id) in self._ports
|
||||||
|
]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
if not matches:
|
||||||
|
raise FlattenError(
|
||||||
|
f"port {port_id!s} is not present in graph at "
|
||||||
|
f"{_format_path(graph_path)}"
|
||||||
|
)
|
||||||
|
raise FlattenError(
|
||||||
|
f"port {port_id!s} is ambiguous in graph at "
|
||||||
|
f"{_format_path(graph_path)}; port IDs must be unique within a graph"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_path(path: ComponentPath) -> str:
|
||||||
|
return "/".join(map(str, path)) if path else "<root>"
|
||||||
60
src/bedit_core/bondgraph/network.py
Normal file
60
src/bedit_core/bondgraph/network.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Flat bond-graph network types used by analysis algorithms."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from bedit_core.models import (
|
||||||
|
BondConnection,
|
||||||
|
BondPort,
|
||||||
|
Component,
|
||||||
|
ComponentID,
|
||||||
|
ConnectionID,
|
||||||
|
PortID,
|
||||||
|
)
|
||||||
|
|
||||||
|
ComponentPath = tuple[ComponentID, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(eq=False)
|
||||||
|
class NetworkPort:
|
||||||
|
"""A bond port together with its owning component and hierarchical path."""
|
||||||
|
|
||||||
|
component_path: ComponentPath
|
||||||
|
component: Component
|
||||||
|
port_id: PortID
|
||||||
|
port: BondPort
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NetworkBond:
|
||||||
|
"""A bond connection whose endpoints are resolved to flattened ports."""
|
||||||
|
|
||||||
|
component_path: ComponentPath
|
||||||
|
connection_id: ConnectionID
|
||||||
|
connection: BondConnection
|
||||||
|
source: NetworkPort
|
||||||
|
target: NetworkPort
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BondGraphNetwork:
|
||||||
|
"""All components, bond ports, and bonds in a component hierarchy."""
|
||||||
|
|
||||||
|
root: Component
|
||||||
|
components: list[Component] = field(default_factory=list)
|
||||||
|
ports: list[NetworkPort] = field(default_factory=list)
|
||||||
|
bonds: list[NetworkBond] = field(default_factory=list)
|
||||||
|
|
||||||
|
def bonds_for(self, port: NetworkPort) -> list[NetworkBond]:
|
||||||
|
"""Return all bonds incident to ``port``."""
|
||||||
|
return [
|
||||||
|
bond
|
||||||
|
for bond in self.bonds
|
||||||
|
if bond.source is port or bond.target is port
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connections(self) -> list[NetworkBond]:
|
||||||
|
"""Alias for ``bonds`` for callers using connection terminology."""
|
||||||
|
return self.bonds
|
||||||
@@ -46,6 +46,8 @@ class PortCausality(Enum):
|
|||||||
PREFERRED_FLOW_OUT = "preferred_flow_out"
|
PREFERRED_FLOW_OUT = "preferred_flow_out"
|
||||||
LIKES_EFFORT_OUT = "likes_effort_out"
|
LIKES_EFFORT_OUT = "likes_effort_out"
|
||||||
LIKES_FLOW_OUT = "likes_flow_out"
|
LIKES_FLOW_OUT = "likes_flow_out"
|
||||||
|
SINGLE_EFFORT_IN = "single_effort_in"
|
||||||
|
SINGLE_FLOW_IN = "single_flow_in"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Document:
|
class Document:
|
||||||
|
|||||||
Reference in New Issue
Block a user