From bb15d77c71f5407a137c0571859e715797b5b647 Mon Sep 17 00:00:00 2001 From: arun3688 Date: Mon, 21 Sep 2026 11:31:33 +0200 Subject: [PATCH 1/3] resolve top level system/subsystem boundary-connector connections instead of dropping them --- src/OMSimulatorPython/instantiated_model.py | 78 +++++++++++++++++++-- src/OMSimulatorPython/system.py | 49 ++++++++++--- 2 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/OMSimulatorPython/instantiated_model.py b/src/OMSimulatorPython/instantiated_model.py index 29507fe31..8fbefe007 100644 --- a/src/OMSimulatorPython/instantiated_model.py +++ b/src/OMSimulatorPython/instantiated_model.py @@ -71,6 +71,7 @@ def __init__(self, json_description, ssdName: str, system: System, resources: di self.modelName = "model" ## create random name, but we cannot commits test as jenkins will gerate new model name self.apiCall = [] self.mappedCrefs = {} # Store mapped CRefs associated with their export names + self.boundaryConnections = [] # system/subsystem boundary-connector pass-throughs, see _applyBoundaryConnections self.system = system self.ssdName = ssdName self.resources = resources @@ -251,6 +252,13 @@ def __init__(self, json_description, ssdName: str, system: System, resources: di ## iterate start values from sub-system both inline and ssv files if exist self.setStartValuesFromElements(self.system.elements, self.system.name) + ## resolve boundary-connector pass-throughs (see System.processElements) + ## after start values are in place, so the copied value is the real one. + ## Kept as self.boundaryConnections so a later setValue() can re-resolve + ## them too (see setValue below). + self.boundaryConnections = config.get("boundary connections", []) + self._applyBoundaryConnections(self.boundaryConnections) + self.apiCall.append(f'oms_instantiate("{self.modelName}")') status = Capi.instantiate(self.modelName) if status != Status.ok: @@ -389,6 +397,60 @@ def apply_start_value(self, value_path:str, value, type): case _: raise TypeError(f"Unsupported type: {type}") + def _applyBoundaryConnections(self, boundary_connections: list): + """Resolve pure system/subsystem boundary-connector connections (see + System.processElements) by copying the value across instead of a live + oms_addConnection, which OMSimulator's WC master algorithm rejects for + an edge between two connectors that aren't a component's own. + + Applied once per connection, repeated len(boundary_connections) times so + a chain of nested pass-throughs (root -> subsystem -> subsystem) fully + settles regardless of declaration order; safe since the connection graph + is a DAG (SSP connections cannot form a cycle back to their own start).""" + for _ in range(len(boundary_connections)): + for connection in boundary_connections: + start = ".".join(connection["start element"] + [connection["start connector"]]) + end = ".".join(connection["end element"] + [connection["end connector"]]) + if start not in self.mappedCrefs: + raise KeyError(f"No mapping found for {start}") + if end not in self.mappedCrefs: + raise KeyError(f"No mapping found for {end}") + self._copyValue(self.mappedCrefs[start], self.mappedCrefs[end]) + + def _copyValue(self, source_path: str, target_path: str): + type, status = Capi.getVariableType(source_path) + if status != Status.ok: + raise RuntimeError(f"Failed to get variable type for {source_path}: {status}") + + match SignalType(type): + case SignalType.Real | SignalType.Float32 | SignalType.Float64: + value, status = Capi.getReal(source_path) + if status != Status.ok: + raise RuntimeError(f"Failed to get real value for {source_path}: {status}") + self._setReal(target_path, value) + case SignalType.Integer | SignalType.Int8 | SignalType.UInt8 | SignalType.Int16 | SignalType.UInt16 | SignalType.Int32 | SignalType.UInt32 | SignalType.Int64 | SignalType.UInt64: + value, status = Capi.getInteger(source_path) + if status != Status.ok: + raise RuntimeError(f"Failed to get integer value for {source_path}: {status}") + self._setInteger(target_path, value) + case SignalType.Boolean: + value, status = Capi.getBoolean(source_path) + if status != Status.ok: + raise RuntimeError(f"Failed to get boolean value for {source_path}: {status}") + self._setBoolean(target_path, value) + case SignalType.String: + value, status = Capi.getString(source_path) + if status != Status.ok: + raise RuntimeError(f"Failed to get string value for {source_path}: {status}") + self._setString(target_path, value) + case SignalType.Enumeration: + value, status = Capi.getInteger(source_path) + if status != Status.ok: + raise RuntimeError(f"Failed to get enumeration value for {source_path}: {status}") + self._setInteger(target_path, value) + case _: + raise TypeError(f"Unsupported type: {type}") + def _addConnectorsRecursive(self, system: System, system_name: str): """Walk the system tree and add connectors for every system/subsystem that was actually instantiated (i.e. present in mappedCrefs).""" @@ -511,18 +573,24 @@ def setValue(self, cref: CRef, value): ## TODO handle FMi3 data types directly, like Float64, Int32,etc.. match SignalType(type): case SignalType.Real: # oms_signal_type_real - return self._setReal(value_path, float(value_)) + result = self._setReal(value_path, float(value_)) case SignalType.Integer: # oms_signal_type_integer - return self._setInteger(value_path, int(value_)) + result = self._setInteger(value_path, int(value_)) case SignalType.Boolean: # oms_signal_type_boolean - return self._setBoolean(value_path, bool(value_)) + result = self._setBoolean(value_path, bool(value_)) case SignalType.String: # oms_signal_type_string - return self._setString(value_path, str(value_)) + result = self._setString(value_path, str(value_)) case SignalType.Enumeration: # oms_signal_type_enumeration - return self._setInteger(value_path, int(value_)) # Treat enumeration as integer + result = self._setInteger(value_path, int(value_)) # Treat enumeration as integer case _: raise TypeError(f"Unsupported type: {type}") + ## re-resolve boundary-connector pass-throughs (see System.processElements + ## and _applyBoundaryConnections) in case `cref` feeds one -- a one-time + ## copy at instantiate() time alone would go stale for a later setValue(). + self._applyBoundaryConnections(self.boundaryConnections) + return result + def _setReal(self, mapped_cref: str, value: float): self.apiCall.append(f'oms_setReal("{mapped_cref}", {value})') return Capi.setReal(mapped_cref, value) # Get the value from the CAPI diff --git a/src/OMSimulatorPython/system.py b/src/OMSimulatorPython/system.py index 8692e897e..e9426aea7 100644 --- a/src/OMSimulatorPython/system.py +++ b/src/OMSimulatorPython/system.py @@ -51,6 +51,11 @@ logger = logging.getLogger(__name__) +# Solver methods that produce a WC (weak coupling / master algorithm) unit; +# shared by generateJson's own-unit validation and processElements' fallback +# routing of connections that cannot be tied to a single component solver. +_WC_METHODS = {"oms_ma", "oms_mav", "oms_mav2"} + class SystemGeometry: def __init__(self, x1 : float | None = None, y1 : float | None = None, x2 : float | None = None, y2 : float | None = None): self._x1 = x1 @@ -936,9 +941,13 @@ def generateJson(self, resources: dict | None = None, tempdir : str | None = Non componentSolver = {} # dict to group connections by solver unit solver_connections = defaultdict(list) + # connections where neither endpoint is a component (a pure system/ + # subsystem boundary-connector pass-through); resolved by value-copy + # instead of a live oms_addConnection, see the loop in processElements + boundary_connections = [] # process the elements - self.processElements(self.elements, self.connections, data, solver_groups, componentSolver, solver_connections, resources, tempdir) + self.processElements(self.elements, self.connections, data, solver_groups, componentSolver, solver_connections, resources, tempdir, boundary_connections) ## group the simulation units for solver, components in solver_groups.items(): @@ -965,8 +974,9 @@ def generateJson(self, resources: dict | None = None, tempdir : str | None = Non raise ValueError(f"Solver '{solver}' not found in solver list.") data["simulation units"].append(unit) + data["boundary connections"] = boundary_connections + # Validate: at most one WC-method solver unit is allowed (nested WC under WC is not supported) - _WC_METHODS = {"oms_ma", "oms_mav", "oms_mav2"} wc_units = [u for u in data["simulation units"] if u.get("solver", {}).get("method") in _WC_METHODS] if len(wc_units) > 1: wc_names = [u["solver"]["name"] for u in wc_units] @@ -989,7 +999,7 @@ def generateJson(self, resources: dict | None = None, tempdir : str | None = Non json_string = json.dumps(data, indent=2) return json_string - def processElements(self, elements_dict: dict, connections: list, data: dict, solver_groups : defaultdict, componentSolver : dict, solver_connections : defaultdict, resources :dict, tempdir : str, systemName = None): + def processElements(self, elements_dict: dict, connections: list, data: dict, solver_groups : defaultdict, componentSolver : dict, solver_connections : defaultdict, resources :dict, tempdir : str, boundary_connections: list, systemName = None): """Processes the elements and connections in the system.""" for key, element in elements_dict.items(): if isinstance(element, Component): @@ -1071,7 +1081,7 @@ def processElements(self, elements_dict: dict, connections: list, data: dict, so }) elif isinstance(element, System): # recurse into subsystems - self.processElements(element.elements, element.connections, data, solver_groups, componentSolver, solver_connections, resources, tempdir, systemName=str(element.name)) + self.processElements(element.elements, element.connections, data, solver_groups, componentSolver, solver_connections, resources, tempdir, boundary_connections, systemName=str(element.name)) for connection in connections: startElement = str(connection.startElement) @@ -1079,6 +1089,29 @@ def processElements(self, elements_dict: dict, connections: list, data: dict, so startSolver = componentSolver.get(startElement, None) endSolver = componentSolver.get(endElement, None) + connection_info = { + "start element": [self.name] + ([systemName] if systemName else []) + ([startElement] if startElement else []), + "start connector": str(connection.startConnector), + "end element": [self.name] + ([systemName] if systemName else []) + ([endElement] if endElement else []), + "end connector": str(connection.endConnector) + } + + if startSolver is None and endSolver is None: + # Neither endpoint is a component -- a pure top-level/subsystem + # boundary-connector pass-through (e.g. a top-level input exposing a + # subsystem's own input). OMSimulator's WC master algorithm has no + # concept of an input-to-input edge and rejects it outright + # ("[updateDependencyGraphs] failed for ..." at initialize, confirmed + # by direct Capi testing), so this is never turned into a live + # oms_addConnection. Resolved instead in InstantiatedModel by + # copying the value across after start values are applied. + boundary_connections.append(connection_info) + continue + + ##TODO: connections between components on two DIFFERENT solver units + ## (a genuine cross-unit connection) still resolve startSolver != + ## endSolver here and fall through to solver=None below, which is + ## silently dropped -- same underlying gap, not covered by this fix. solver = None if startSolver == endSolver and startSolver is not None: solver = startSolver @@ -1086,13 +1119,7 @@ def processElements(self, elements_dict: dict, connections: list, data: dict, so solver = endSolver elif endSolver is None and startSolver is not None: solver = startSolver - ##TODO group components and connection without solver information, right now they are grouped under NONE category - connection_info = { - "start element": [self.name] + ([systemName] if systemName else []) + ([startElement] if startElement else []), - "start connector": str(connection.startConnector), - "end element": [self.name] + ([systemName] if systemName else []) + ([endElement] if endElement else []), - "end connector": str(connection.endConnector) - } + ## add linear transformation info if available if connection.linearTransformation: connection_info["linear transformation"] = { From c2c92780894890d363a8e6330f5e5a3abc25fa6e Mon Sep 17 00:00:00 2001 From: arun3688 Date: Mon, 21 Sep 2026 12:22:52 +0200 Subject: [PATCH 2/3] add tests --- testsuite/tests/simulation/CMakeLists.txt | 1 + .../tests/simulation/SimpleSimulation18.py | 94 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 testsuite/tests/simulation/SimpleSimulation18.py diff --git a/testsuite/tests/simulation/CMakeLists.txt b/testsuite/tests/simulation/CMakeLists.txt index 2e7c51615..68de9ed73 100644 --- a/testsuite/tests/simulation/CMakeLists.txt +++ b/testsuite/tests/simulation/CMakeLists.txt @@ -30,6 +30,7 @@ oms_add_test( SimpleSimulation15.py SimpleSimulation16.py SimpleSimulation17.py + SimpleSimulation18.py stepUntil.py FMI3_SimpleSimulation1.py FMI3_SimpleSimulation2.py diff --git a/testsuite/tests/simulation/SimpleSimulation18.py b/testsuite/tests/simulation/SimpleSimulation18.py new file mode 100644 index 000000000..e25cd67fc --- /dev/null +++ b/testsuite/tests/simulation/SimpleSimulation18.py @@ -0,0 +1,94 @@ +## status: correct +## linux: yes +## ucrt64: yes +## win: yes +## mac: yes +## asan: no + +from OMSimulator import SSP, CRef, Settings, Connector, Causality, SignalType + +Settings.suppressPath = True + + +model = SSP() +## add top level system connector +model.activeVariant.system.addConnector(Connector('input1', Causality.input, SignalType.Real)) +model.addResource('../resources/Modelica.Blocks.Math.Add3.fmu', new_name='resources/Add.fmu') + +## add subsystem +model.addSystem(CRef('default', 'sub-system')) +model.addComponent(CRef('default', 'sub-system', 'Add'), 'resources/Add.fmu') + +## add top level sub-system connector +model.activeVariant.system.elements[CRef('sub-system')].addConnector(Connector('input', Causality.input, SignalType.Real)) + + +model.setValue(CRef('default','input1'), 300.0) + +model.addConnection(CRef('default', 'input1'), CRef('default', 'sub-system', 'input')) +model.addConnection(CRef('default', 'sub-system', 'input'), CRef('default', 'sub-system', 'Add', 'u1')) +model.list() +instantiated_model = model.instantiate() ## internally generate the json file and also set the model state like virgin, +#print(instantiated_model.dumpApiCalls(), flush=True) +instantiated_model.setResultFile("SimpleSimulation18_res.mat") +#instantiated_model.setValue(CRef('default','input1'), 400.0) +print(f"info: After instantiation:") +print(f"info: default.input1 : {instantiated_model.getValue(CRef('default', 'input1'))}", flush=True) +print(f"info: default.sub-system.input: {instantiated_model.getValue(CRef('default', 'sub-system', 'input'))}", flush=True) +print(f"info: default.sub-system.Add.u1: {instantiated_model.getValue(CRef('default', 'sub-system', 'Add', 'u1'))}", flush=True) + + +instantiated_model.initialize() +instantiated_model.simulate() +print(f"info: After simulation:") +print(f"info: default.input1 : {instantiated_model.getValue(CRef('default', 'input1'))}", flush=True) +print(f"info: default.sub-system.input: {instantiated_model.getValue(CRef('default', 'sub-system', 'input'))}", flush=True) +print(f"info: default.sub-system.Add.u1: {instantiated_model.getValue(CRef('default', 'sub-system', 'Add', 'u1'))}", flush=True) + +instantiated_model.terminate() +instantiated_model.delete() + +## Result: +## +## |-- Resources: +## |-- resources/Add.fmu +## |-- Active Variant: default +## |-- +## |-- Variant "default": None +## |-- |-- System: default 'None' +## |-- |-- |-- Connectors: +## |-- |-- |-- |-- (input1, Causality.input, SignalType.Real, None, 'None') +## |-- |-- |-- Inline Parameter Bindings: +## |-- |-- |-- |-- (Real input1, 300.0, None, 'None') +## |-- |-- |-- Elements: +## |-- |-- |-- |-- System: sub-system 'None' +## |-- |-- |-- |-- |-- Connectors: +## |-- |-- |-- |-- |-- |-- (input, Causality.input, SignalType.Real, None, 'None') +## |-- |-- |-- |-- |-- Elements: +## |-- |-- |-- |-- |-- |-- FMU: Add 'None' +## |-- |-- |-- |-- |-- |-- |-- path: resources/Add.fmu +## |-- |-- |-- |-- |-- |-- |-- Connectors: +## |-- |-- |-- |-- |-- |-- |-- |-- (u1, Causality.input, SignalType.Real, None, 'Connector of Real input signal 1') +## |-- |-- |-- |-- |-- |-- |-- |-- (u2, Causality.input, SignalType.Real, None, 'Connector of Real input signal 2') +## |-- |-- |-- |-- |-- |-- |-- |-- (u3, Causality.input, SignalType.Real, None, 'Connector of Real input signal 3') +## |-- |-- |-- |-- |-- |-- |-- |-- (y, Causality.output, SignalType.Real, None, 'Connector of Real output signal') +## |-- |-- |-- |-- |-- |-- |-- |-- (k1, Causality.parameter, SignalType.Real, None, 'Gain of input signal 1') +## |-- |-- |-- |-- |-- |-- |-- |-- (k2, Causality.parameter, SignalType.Real, None, 'Gain of input signal 2') +## |-- |-- |-- |-- |-- |-- |-- |-- (k3, Causality.parameter, SignalType.Real, None, 'Gain of input signal 3') +## |-- |-- |-- |-- |-- Connections: +## |-- |-- |-- |-- |-- |-- .input -> Add.u1 +## |-- |-- |-- Connections: +## |-- |-- |-- |-- .input1 -> sub-system.input +## |-- DefaultExperiment +## |-- |-- startTime: 0.0 +## |-- |-- stopTime: 1.0 +## info: After instantiation: +## info: default.input1 : 300.0 +## info: default.sub-system.input: 300.0 +## info: default.sub-system.Add.u1: 0.0 +## info: Result file: SimpleSimulation18_res.mat (bufferSize=10) +## info: After simulation: +## info: default.input1 : 300.0 +## info: default.sub-system.input: 300.0 +## info: default.sub-system.Add.u1: 300.0 +## endResult From b6248b04f407f3fb28cbe9316373fd725e3f31c3 Mon Sep 17 00:00:00 2001 From: arun3688 Date: Mon, 21 Sep 2026 13:55:57 +0200 Subject: [PATCH 3/3] trigger build