Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions src/OMSimulatorPython/instantiated_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -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
Expand Down
49 changes: 38 additions & 11 deletions src/OMSimulatorPython/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand All @@ -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]
Expand All @@ -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):
Expand Down Expand Up @@ -1071,28 +1081,45 @@ 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)
endElement = str(connection.endElement)
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
elif startSolver is None and endSolver is not None:
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"] = {
Expand Down
1 change: 1 addition & 0 deletions testsuite/tests/simulation/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ oms_add_test(
SimpleSimulation15.py
SimpleSimulation16.py
SimpleSimulation17.py
SimpleSimulation18.py
stepUntil.py
FMI3_SimpleSimulation1.py
FMI3_SimpleSimulation2.py
Expand Down
94 changes: 94 additions & 0 deletions testsuite/tests/simulation/SimpleSimulation18.py
Original file line number Diff line number Diff line change
@@ -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:
## <class 'OMSimulator.ssp.SSP'>
## |-- Resources:
## |-- resources/Add.fmu
## |-- Active Variant: default
## |-- <class 'OMSimulator.ssd.SSD'>
## |-- 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