Fixed some bugs

This commit is contained in:
2026-07-22 19:16:56 +02:00
parent 2d49f65ec4
commit 8a6203d01e
7 changed files with 243 additions and 221 deletions

View File

@@ -1,21 +0,0 @@
model m_Test
model m_Constant0
output Real y(quantity="Length", unit="m");
parameter Real v = 2;
equation
y = v;
end m_Constant0;
model m_Gain0
input Real u;
output Real y;
parameter Real k(quantity="Current", unit="A") = 2.5;
equation
y = k*u;
end m_Gain0;
m_Constant0 Constant0;
m_Gain0 Gain0;
equation
Gain0.u = Constant0.y;
annotation(
experiment(StartTime = 0, StopTime = 1, Tolerance = 1e-06, Interval = 0.002));
end m_Test;

View File

@@ -25,8 +25,6 @@ def infer_causality(component: dict[str, Any], toplevel: bool = True) -> dict[st
""" """
if toplevel: if toplevel:
log.info("Causality inference")
# Reset causalities # Reset causalities
reset_causality(component) reset_causality(component)
@@ -61,20 +59,40 @@ def infer_causality(component: dict[str, Any], toplevel: bool = True) -> dict[st
# Soft choices # Soft choices may need several passes when a junction has multiple
for block in graph.get("blocks", []): # unresolved bonds. Stop as soon as a pass makes no further progress.
for port in block.get('interface', {}).get('ports', []): while True:
if port.get('type', '') != 'power': unresolved_before = sum(
continue con.get('type') == 'power' and con.get('causality', 'none') == 'none'
if is_port_fully_assigned(block, port, graph): for con in graph.get('connections', [])
continue )
if port.get('causality', 'indifferent') == 'likes effort out': for block in graph.get("blocks", []):
propagate_from_port(block, port, 'effort out', graph, id_list) for port in block.get('interface', {}).get('ports', []):
elif port.get('causality', 'indifferent') == 'likes flow out': if port.get('type', '') != 'power':
propagate_from_port(block, port, 'flow out', graph, id_list) continue
elif port.get('causality', 'indifferent') in ['indifferent', 'single flow in', 'single effort in']: if is_port_fully_assigned(block, port, graph):
# Force an arbitrary assignment on the first unassigned bond connected to this port continue
propagate_from_port(block, port, 'effort out', graph, id_list) if port.get('causality', 'indifferent') == 'likes effort out':
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'likes flow out':
propagate_from_port(block, port, 'flow out', graph, id_list)
elif port.get('causality', 'indifferent') == 'single effort in':
evaluate_junction_constraints(block, port, graph, id_list)
if not is_port_fully_assigned(block, port, graph):
propagate_from_port(block, port, 'flow out', graph, id_list)
elif port.get('causality', 'indifferent') == 'single flow in':
evaluate_junction_constraints(block, port, graph, id_list)
if not is_port_fully_assigned(block, port, graph):
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'indifferent':
# Force an arbitrary assignment on the first unassigned bond connected to this port
propagate_from_port(block, port, 'effort out', graph, id_list)
unresolved_after = sum(
con.get('type') == 'power' and con.get('causality', 'none') == 'none'
for con in graph.get('connections', [])
)
if unresolved_after == 0 or unresolved_after >= unresolved_before:
break
# Last check # Last check
for con in graph.get('connections', []): for con in graph.get('connections', []):

View File

@@ -80,6 +80,7 @@ def emit_model(
id_list: dict[str, Any], id_list: dict[str, Any],
indent: int = 0, indent: int = 0,
connection_counts: dict[str, int] | None = None, connection_counts: dict[str, int] | None = None,
connection_signs: dict[str, list[float]] | None = None,
) -> str: ) -> str:
"""Emit a component and its nested definitions as Modelica source.""" """Emit a component and its nested definitions as Modelica source."""
@@ -92,16 +93,31 @@ def emit_model(
implementation_kind = implementation.get("kind") implementation_kind = implementation.get("kind")
nested_graph = implementation.get("graph", {}) nested_graph = implementation.get("graph", {})
port_counts = connection_counts or _interface_connection_counts(graph) port_counts = connection_counts or _interface_connection_counts(graph)
macros = _port_count_macros(graph, port_counts) port_signs = connection_signs or _interface_connection_signs(graph)
macros = _port_connection_macros(graph, port_counts, port_signs)
if indent == 0 and _contains_power_port(graph):
lines.extend(
(
f"{body_indent}connector BondPort \"Bond graph power port\"",
f"{body_indent}\tReal e \"Effort variable\";",
f"{body_indent}\tflow Real f \"Flow variable\";",
f"{body_indent}end BondPort;",
)
)
if implementation_kind == "graph": if implementation_kind == "graph":
for block in nested_graph.get("blocks", []): for block in nested_graph.get("blocks", []):
block_counts, block_signs = _block_connection_data(
nested_graph, block["id"]
)
lines.extend( lines.extend(
emit_model( emit_model(
block, block,
{}, {},
indent + 1, indent + 1,
_block_connection_counts(nested_graph, block["id"]), block_counts,
block_signs,
) )
.rstrip() .rstrip()
.splitlines() .splitlines()
@@ -157,8 +173,10 @@ def emit_model(
target = _endpoint_expression( target = _endpoint_expression(
connection["target"], graph, blocks, junctions, endpoint_indices connection["target"], graph, blocks, junctions, endpoint_indices
) )
# Connector types may require different equations in future. if connection.get("type") == "power":
lines.append(f"{body_indent}{target} = {source};") lines.append(f"{body_indent}connect({source}, {target});")
else:
lines.append(f"{body_indent}{target} = {source};")
else: else:
initial_equations = str( initial_equations = str(
implementation.get("source", {}).get("initialEquations", "") implementation.get("source", {}).get("initialEquations", "")
@@ -213,10 +231,14 @@ def cleanup_graph(graph: dict[str, Any]) -> dict[str, Any]:
def _port_declaration( def _port_declaration(
port: dict[str, Any], direction: str, indent: int, macros: dict[str, str] port: dict[str, Any], direction: str, indent: int, macros: dict[str, str]
) -> str: ) -> str:
port_type = modelica_type(port.get("valueType", "real"))
indentation = "\t" * indent indentation = "\t" * indent
port_name = identifier(port["name"]) port_name = identifier(port["name"])
dimensions = _port_dimensions(port, port_name) dimensions = _port_dimensions(port, port_name)
if port.get("type") == "power":
return expand_bevalues(
f"{indentation}BondPort {port_name}{dimensions};", macros
)
port_type = modelica_type(port.get("valueType", "real"))
attributes = _quantity_unit_attributes(port) attributes = _quantity_unit_attributes(port)
declaration = ( declaration = (
f"{indentation}{direction} {port_type} {port_name}{dimensions}{attributes};" f"{indentation}{direction} {port_type} {port_name}{dimensions}{attributes};"
@@ -299,16 +321,21 @@ def _index_array_endpoint(
return f"{expression}[{endpoint_indices[key]}]" return f"{expression}[{endpoint_indices[key]}]"
def _block_connection_counts( def _block_connection_data(
graph: dict[str, Any], block_id: str graph: dict[str, Any], block_id: str
) -> dict[str, int]: ) -> tuple[dict[str, int], dict[str, list[float]]]:
counts: dict[str, int] = {} counts: dict[str, int] = {}
signs: dict[str, list[float]] = {}
for connection in graph.get("connections", []): for connection in graph.get("connections", []):
for endpoint in (connection.get("source", {}), connection.get("target", {})): for endpoint, sign in (
(connection.get("source", {}), -1.0),
(connection.get("target", {}), 1.0),
):
if endpoint.get("block") == block_id and endpoint.get("port"): if endpoint.get("block") == block_id and endpoint.get("port"):
port_id = endpoint["port"] port_id = endpoint["port"]
counts[port_id] = counts.get(port_id, 0) + 1 counts[port_id] = counts.get(port_id, 0) + 1
return counts signs.setdefault(port_id, []).append(sign)
return counts, signs
def _interface_connection_counts(component: dict[str, Any]) -> dict[str, int]: def _interface_connection_counts(component: dict[str, Any]) -> dict[str, int]:
@@ -324,19 +351,56 @@ def _interface_connection_counts(component: dict[str, Any]) -> dict[str, int]:
return counts return counts
def _port_count_macros( def _interface_connection_signs(component: dict[str, Any]) -> dict[str, list[float]]:
component: dict[str, Any], connection_counts: dict[str, int] implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return {}
signs: dict[str, list[float]] = {}
for connection in implementation.get("graph", {}).get("connections", []):
for endpoint, sign in (
(connection.get("source", {}), -1.0),
(connection.get("target", {}), 1.0),
):
port_id = endpoint.get("interface")
if port_id:
signs.setdefault(port_id, []).append(sign)
return signs
def _port_connection_macros(
component: dict[str, Any],
connection_counts: dict[str, int],
connection_signs: dict[str, list[float]],
) -> dict[str, str]: ) -> dict[str, str]:
macros: dict[str, str] = {} macros: dict[str, str] = {}
interface = component.get("interface", {}) interface = component.get("interface", {})
for port in interface.get("ports", []): for port in interface.get("ports", []):
if port.get("multipleConnections", False): if port.get("multipleConnections", False):
macros[f"{identifier(port['name'])}_N"] = str( macro_name = identifier(port["name"])
connection_counts.get(port["id"], 0) port_id = port["id"]
) macros[f"{macro_name}_N"] = str(connection_counts.get(port_id, 0))
signs = connection_signs.get(port_id, [])
macros[f"{macro_name}_S"] = "{" + ", ".join(
f"{sign:.1f}" for sign in signs
) + "}"
return macros return macros
def _contains_power_port(component: dict[str, Any]) -> bool:
if any(
port.get("type") == "power"
for port in component.get("interface", {}).get("ports", [])
):
return True
implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return False
return any(
_contains_power_port(block)
for block in implementation.get("graph", {}).get("blocks", [])
)
def expand_bevalues(text: str, values: dict[str, str]) -> str: def expand_bevalues(text: str, values: dict[str, str]) -> str:
"""Replace BEdit ``$name$`` macros and reject unresolved composer values.""" """Replace BEdit ``$name$`` macros and reject unresolved composer values."""

View File

@@ -1,5 +1,7 @@
import json import json
import logging
import os import os
import re
import shlex import shlex
import shutil import shutil
import socket import socket
@@ -54,6 +56,7 @@ class ModelBuildResult:
check_summary: str check_summary: str
build_result: Any build_result: Any
diagnostics: str = ""
class OpenModelicaInterface: class OpenModelicaInterface:
@@ -100,21 +103,42 @@ class OpenModelicaInterface:
"""Load and build one composed model as an ordered worker operation.""" """Load and build one composed model as an ordered worker operation."""
def operation(omc, _temp_dir: Path): def operation(omc, _temp_dir: Path):
unit_checking = omc.sendExpression( capture = _LogCapture()
'setCommandLineOptions("--unitChecking")' ompython_logger = logging.getLogger("OMPython")
) previous_propagate = ompython_logger.propagate
if unit_checking is not True: ompython_logger.addHandler(capture)
raise RuntimeError("OpenModelica could not enable unit checking") ompython_logger.propagate = False
loaded = omc.sendExpression(f"loadString({json.dumps(model)})") try:
if loaded is not True: unit_checking = omc.sendExpression(
raise RuntimeError("OpenModelica could not load the composed model") 'setCommandLineOptions("--unitChecking")'
check_summary = omc.sendExpression(f"checkModel({model_name})")
if not isinstance(check_summary, str) or not check_summary.strip():
raise RuntimeError(
"OpenModelica did not return a model-check summary"
) )
build_result = omc.sendExpression(f"buildModel({model_name})") if unit_checking is not True:
return ModelBuildResult(check_summary, build_result) raise RuntimeError("OpenModelica could not enable unit checking")
loaded = omc.sendExpression(f"loadString({json.dumps(model)})")
if loaded is not True:
raise RuntimeError("OpenModelica could not load the composed model")
diagnostics = [_read_omc_diagnostics(omc)]
check_summary = omc.sendExpression(f"checkModel({model_name})")
diagnostics.append(_read_omc_diagnostics(omc))
if not isinstance(check_summary, str) or not check_summary.strip():
raise RuntimeError(
"OpenModelica did not return a model-check summary"
)
raw_build_result = omc.sendExpression(
f"buildModel({model_name})", parsed=False
)
build_result = _parse_modelica_string_array(raw_build_result)
diagnostics.append(_read_omc_diagnostics(omc))
diagnostics.extend(capture.messages)
diagnostic_text = "\n".join(
item.strip()
for item in diagnostics
if isinstance(item, str) and item.strip()
)
return ModelBuildResult(check_summary, build_result, diagnostic_text)
finally:
ompython_logger.removeHandler(capture)
ompython_logger.propagate = previous_propagate
self._submit("build model", operation, callback, error_callback) self._submit("build model", operation, callback, error_callback)
@@ -266,6 +290,51 @@ class OpenModelicaInterface:
shutil.rmtree(temp_dir, ignore_errors=True) shutil.rmtree(temp_dir, ignore_errors=True)
def _read_omc_diagnostics(omc) -> str:
"""Read OMC diagnostics without sending multiline text through OMPython's parser."""
raw = omc.sendExpression("getErrorString()", parsed=False)
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
if not isinstance(raw, str):
return ""
text = raw.strip()
if not text:
return ""
try:
decoded = json.loads(text)
except json.JSONDecodeError:
return text
return decoded if isinstance(decoded, str) else text
class _LogCapture(logging.Handler):
"""Collect dependency log messages for forwarding through BEdit's logger."""
def __init__(self) -> None:
super().__init__(level=logging.WARNING)
self.messages: list[str] = []
def emit(self, record: logging.LogRecord) -> None:
self.messages.append(record.getMessage())
def _parse_modelica_string_array(raw: Any) -> tuple[str, ...]:
"""Parse the string array returned by ``buildModel`` in raw mode."""
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
if not isinstance(raw, str):
raise RuntimeError(f"OpenModelica returned an invalid build result: {raw!r}")
values = tuple(
json.loads(token)
for token in re.findall(r'"(?:\\.|[^"\\])*"', raw)
)
if not values:
raise RuntimeError(f"OpenModelica returned an invalid build result: {raw!r}")
return values
def _deliver_callback(callback: Callable[[Any], None], value: Any) -> None: def _deliver_callback(callback: Callable[[Any], None], value: Any) -> None:
try: try:
callback(value) callback(value)

View File

@@ -45,11 +45,10 @@ class Simulation:
graph: dict[str, Any], graph: dict[str, Any],
callback: Callable[[str], None] | None = None, callback: Callable[[str], None] | None = None,
error_callback: ErrorCallback | None = None, error_callback: ErrorCallback | None = None,
message_callback: Callable[[SimulationMessage], None] | None = None,
) -> None: ) -> None:
"""Compose and retain the active graph's Modelica representation.""" """Compose and retain the active graph's Modelica representation."""
return
self.model_path = None self.model_path = None
self.compose_source(graph) self.compose_source(graph)
@@ -64,6 +63,24 @@ class Simulation:
log.error("%s", failure) log.error("%s", failure)
return return
log.info("%s", result.check_summary.strip()) log.info("%s", result.check_summary.strip())
if message_callback is not None:
message_callback(
SimulationMessage(
stream="build",
type="info",
text=result.check_summary.strip(),
)
)
if result.diagnostics:
log.warning("OpenModelica build diagnostics:\n%s", result.diagnostics)
if message_callback is not None:
message_callback(
SimulationMessage(
stream="build",
type="warning",
text=result.diagnostics,
)
)
log.info("Compiling OK: %s", result.build_result) log.info("Compiling OK: %s", result.build_result)
try: try:
self.model_path = str(result.build_result[0]) self.model_path = str(result.build_result[0])
@@ -77,6 +94,14 @@ class Simulation:
else: else:
log.error("%s", failure) log.error("%s", failure)
return return
if message_callback is not None:
message_callback(
SimulationMessage(
stream="build",
type="info",
text=f"Compiling OK: {result.build_result}",
)
)
if callback is not None: if callback is not None:
callback(self.model_path) callback(self.model_path)
@@ -134,7 +159,7 @@ class Simulation:
error_callback, error_callback,
) )
self.compose(graph, run_model, error_callback) self.compose(graph, run_model, error_callback, message_callback)
def get_progress(self) -> SimulationProgress | None: def get_progress(self) -> SimulationProgress | None:
"""Return the most recently received simulation status.""" """Return the most recently received simulation status."""

View File

@@ -177,13 +177,13 @@
"source": { "source": {
"equations": "der(state) = p.f;\np.e = state/c;", "equations": "der(state) = p.f;\np.e = state/c;",
"declarations": "", "declarations": "",
"initialEquations": "" "initialEquations": "state = 0;"
} }
} }
}, },
{ {
"id": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6", "id": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6",
"name": "1", "name": "j1",
"position": { "position": {
"x": -96.0, "x": -96.0,
"y": -160.0 "y": -160.0
@@ -256,8 +256,8 @@
"implementation": { "implementation": {
"kind": "text", "kind": "text",
"source": { "source": {
"equations": "flow = p[1].f;\nsum(p[i].e for i in 1:$p_N$) = 0;\nfor i in 2:$p_N$ loop\n p[i].f = p[i-1].f;\nend for;", "equations": "f = s[1] * p[1].f;\nsum(s[i] * p[i].e for i in 1:$p_N$) = 0;\nfor i in 2:$p_N$ loop\n s[i] * p[i].f = s[i-1] * p[i-1].f;\nend for;",
"declarations": "Real flow;", "declarations": "parameter Real s[$p_N$] = $p_S$;\nReal f;",
"initialEquations": "" "initialEquations": ""
} }
} }
@@ -399,7 +399,7 @@
"parameters": [ "parameters": [
{ {
"id": "parameter-d056bc27", "id": "parameter-d056bc27",
"name": "effort", "name": "e",
"type": "real", "type": "real",
"value": "1", "value": "1",
"quantity": "", "quantity": "",
@@ -446,15 +446,15 @@
"implementation": { "implementation": {
"kind": "text", "kind": "text",
"source": { "source": {
"equations": "p.e = effort;\nflow = p.f;", "equations": "p.e = e;\nf = p.f;",
"declarations": "Real flow;", "declarations": "Real f;",
"initialEquations": "" "initialEquations": ""
} }
} }
}, },
{ {
"id": "140ae14b-9dbb-4756-aa25-29101ef2a99e", "id": "140ae14b-9dbb-4756-aa25-29101ef2a99e",
"name": "0", "name": "j0",
"position": { "position": {
"x": -96.0, "x": -96.0,
"y": -32.0 "y": -32.0
@@ -527,130 +527,8 @@
"implementation": { "implementation": {
"kind": "text", "kind": "text",
"source": { "source": {
"equations": "effort = p[1].e;\nsum(p[i].f for i in 1:$p_N$) = 0;\nfor i in 2:$p_N$ loop\n p[i].e = p[i-1].e;\nend for;", "equations": "e = p[1].e;\nsum(p[i].f for i in 1:$p_N$) = 0;\nfor i in 2:$p_N$ loop\n p[i].e = p[i-1].e;\nend for;",
"declarations": "Real effort;", "declarations": "Real e;",
"initialEquations": ""
}
}
},
{
"id": "6a6fbadd-28ec-4d10-b854-876752a6b287",
"name": "C0",
"position": {
"x": -96.0,
"y": 96.0
},
"rotation": 0.0,
"interface": {
"ports": [
{
"id": "port-2e1d884f",
"name": "p",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "power",
"multipleConnections": false,
"valueType": "real",
"quantity": "",
"unit": "",
"dimensions": {
"rows": 1,
"columns": 1
},
"description": "",
"orientation": "input",
"domain": "power",
"causality": "preferred effort out"
},
{
"id": "port-ca7716d5",
"name": "state",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false,
"valueType": "real",
"quantity": "",
"unit": "",
"dimensions": {
"rows": 1,
"columns": 1
},
"description": "",
"orientation": "output",
"domain": "power",
"causality": "indifferent"
}
]
},
"parameters": [
{
"id": "parameter-d056bc27",
"name": "c",
"type": "real",
"value": "1",
"quantity": "",
"unit": "",
"dimensions": {
"rows": 1,
"columns": 1
},
"description": ""
}
],
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"type": "text",
"x": 40.0,
"y": 40.0,
"width": 48.0,
"height": 48.0,
"text": "C",
"color": "#303030",
"fontSize": 32.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"fill": "#ffffff"
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "der(state) = p.f;\np.e = state/c;",
"declarations": "",
"initialEquations": "" "initialEquations": ""
} }
} }
@@ -699,7 +577,7 @@
"id": "parameter-d056bc27", "id": "parameter-d056bc27",
"name": "r", "name": "r",
"type": "real", "type": "real",
"value": "1", "value": "10",
"quantity": "", "quantity": "",
"unit": "", "unit": "",
"dimensions": { "dimensions": {
@@ -868,7 +746,7 @@
"source": { "source": {
"equations": "der(state) = p.e;\np.f = state/i;", "equations": "der(state) = p.e;\np.f = state/i;",
"declarations": "", "declarations": "",
"initialEquations": "" "initialEquations": "state = 0;"
} }
} }
} }
@@ -906,23 +784,6 @@
"waypoints": [] "waypoints": []
}, },
"type": "power", "type": "power",
"causality": "target"
},
{
"id": "ae95fbfc-c771-47f2-8f9e-1c823daaf6b8",
"source": {
"block": "140ae14b-9dbb-4756-aa25-29101ef2a99e",
"port": "port-3e53bf80"
},
"target": {
"block": "6a6fbadd-28ec-4d10-b854-876752a6b287",
"port": "port-2e1d884f"
},
"name": "",
"properties": {
"waypoints": []
},
"type": "power",
"causality": "source" "causality": "source"
}, },
{ {
@@ -977,7 +838,7 @@
"causality": "target" "causality": "target"
}, },
{ {
"id": "d6940de3-5b4c-42be-a247-639b3803d85e", "id": "e67f412c-39ae-404d-a513-d4a0c389ef40",
"source": { "source": {
"block": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6", "block": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6",
"port": "port-3e53bf80" "port": "port-3e53bf80"
@@ -996,7 +857,13 @@
], ],
"annotations": [], "annotations": [],
"junctions": [], "junctions": [],
"simulation": {} "simulation": {
"startTime": 0.0,
"stopTime": 10.0,
"intervalMode": "numberOfIntervals",
"numberOfIntervals": 500,
"intervalTime": 0.002
}
} }
} }
} }