Skip to content

Container

ArrayAggregate

Represents an FMI-2 array-element family (basename[i], basename[i,j], ...) aggregated as a virtual N-D array so it can be linked to an FMI-3 array port of matching shape.

Attributes:

Name Type Description
basename

Name of the virtual aggregate (without brackets).

dims

Shape as a tuple of positive integers, e.g. (3,) for a 1D vector of length 3, (2, 3) for a 2×3 matrix.

ordered_element_names

Original scalar port names sorted in row-major order (last index varies fastest), matching the FMI-3 array memory layout.

Source code in fmu_manipulation_toolbox/container.py
class ArrayAggregate:
    """Represents an FMI-2 array-element family (`basename[i]`, `basename[i,j]`,
    ...) aggregated as a virtual N-D array so it can be linked to an FMI-3
    array port of matching shape.

    Attributes:
        basename: Name of the virtual aggregate (without brackets).
        dims: Shape as a tuple of positive integers, e.g. `(3,)` for a 1D
            vector of length 3, `(2, 3)` for a 2×3 matrix.
        ordered_element_names: Original scalar port names sorted in
            **row-major** order (last index varies fastest), matching the
            FMI-3 array memory layout.
    """

    __slots__ = ("basename", "dims", "ordered_element_names")

    # Trailing bracket group: `[3]`, `[1,2]`, ... (Modelica-style, single
    # bracket with comma-separated indices) at the end of a name.
    _ARRAY_ELEM_RE = re.compile(r"^(.+)\[(\d+(?:,\d+)*)]$")

    def __init__(self, basename: str, dims: Tuple[int, ...], ordered_element_names: List[str]):
        self.basename = basename
        self.dims = dims
        self.ordered_element_names = ordered_element_names

    @property
    def size(self) -> int:
        """Total number of scalar elements (product of `dims`)."""
        return len(self.ordered_element_names)

    @property
    def rank(self) -> int:
        """Number of axes (`len(dims)`)."""
        return len(self.dims)

    @property
    def shape_str(self) -> str:
        """Human-readable shape, e.g. `"2x3"` for a 2×3 matrix."""
        return "x".join(str(d) for d in self.dims)

    def __repr__(self):
        return f"ArrayAggregate({self.basename!r}, shape={self.shape_str}, size={self.size})"

    # -- Alternative constructors / parsers ------------------------------------

    @classmethod
    def parse_element_name(cls, name: str) -> Optional[Tuple[str, Tuple[int, ...]]]:
        """Return `(basename, indices)` if `name` has the form `basename[i,j,...]`
        (Modelica-style comma notation, conforming to FMI-2.0 array-element
        naming). Returns `None` if `name` is not a recognized array element
        name.
        """
        m = cls._ARRAY_ELEM_RE.match(name)
        if not m:
            return None
        basename = m.group(1)
        indices = tuple(int(tok) for tok in m.group(2).split(","))
        return basename, indices

    @classmethod
    def detect_all(
            cls,
            port_names: Iterable[str],
            existing_names: Optional[Set[str]] = None,
            log_prefix: str = "",
    ) -> List["ArrayAggregate"]:
        """Detect FMI-2 array-element families among `port_names` and return the
        valid N-D aggregates as `ArrayAggregate` instances.

        Only aggregates whose indices form a complete, contiguous hyperrectangle
        starting at 0 or 1 on every axis are returned. Attribute homogeneity
        (type, causality, ...) is **not** checked here; callers must filter
        further if needed.

        `existing_names` avoids emitting an aggregate whose basename collides
        with an already-existing (scalar) port.
        """
        if existing_names is None:
            existing_names = set()

        groups: Dict[str, List[Tuple[Tuple[int, ...], str]]] = defaultdict(list)
        for name in port_names:
            parsed = cls.parse_element_name(name)
            if parsed is None:
                continue
            basename, indices = parsed
            groups[basename].append((indices, name))

        aggregates: List[ArrayAggregate] = []
        for basename, elements in groups.items():
            if basename in existing_names:
                continue

            rank = len(elements[0][0])
            if not all(len(idx) == rank for idx, _ in elements):
                if log_prefix:
                    logger.debug(f"'{log_prefix}': mixed ranks for array '{basename}', "
                                 f"aggregate not created.")
                continue

            mins = [min(idx[a] for idx, _ in elements) for a in range(rank)]
            maxs = [max(idx[a] for idx, _ in elements) for a in range(rank)]
            if not all(s in (0, 1) for s in mins):
                continue
            dims = tuple(maxs[a] - mins[a] + 1 for a in range(rank))

            expected_count = 1
            for d in dims:
                expected_count *= d
            actual_set = {idx for idx, _ in elements}
            if len(actual_set) != len(elements) or expected_count != len(elements):
                if log_prefix:
                    logger.debug(f"'{log_prefix}': non-contiguous / duplicated array elements "
                                 f"for '{basename}', aggregate not created.")
                continue
            expected_set = set(itertools.product(
                *(range(mins[a], mins[a] + dims[a]) for a in range(rank))))
            if expected_set != actual_set:
                if log_prefix:
                    logger.debug(f"'{log_prefix}': non-contiguous array elements for '{basename}', "
                                 f"aggregate not created.")
                continue

            # Row-major sort: last index varies fastest.
            elements.sort(key=lambda e: e[0])
            ordered_names = [n for _, n in elements]
            aggregates.append(cls(basename, dims, ordered_names))

        return aggregates

rank property

Number of axes (len(dims)).

shape_str property

Human-readable shape, e.g. "2x3" for a 2×3 matrix.

size property

Total number of scalar elements (product of dims).

detect_all(port_names, existing_names=None, log_prefix='') classmethod

Detect FMI-2 array-element families among port_names and return the valid N-D aggregates as ArrayAggregate instances.

Only aggregates whose indices form a complete, contiguous hyperrectangle starting at 0 or 1 on every axis are returned. Attribute homogeneity (type, causality, ...) is not checked here; callers must filter further if needed.

existing_names avoids emitting an aggregate whose basename collides with an already-existing (scalar) port.

Source code in fmu_manipulation_toolbox/container.py
@classmethod
def detect_all(
        cls,
        port_names: Iterable[str],
        existing_names: Optional[Set[str]] = None,
        log_prefix: str = "",
) -> List["ArrayAggregate"]:
    """Detect FMI-2 array-element families among `port_names` and return the
    valid N-D aggregates as `ArrayAggregate` instances.

    Only aggregates whose indices form a complete, contiguous hyperrectangle
    starting at 0 or 1 on every axis are returned. Attribute homogeneity
    (type, causality, ...) is **not** checked here; callers must filter
    further if needed.

    `existing_names` avoids emitting an aggregate whose basename collides
    with an already-existing (scalar) port.
    """
    if existing_names is None:
        existing_names = set()

    groups: Dict[str, List[Tuple[Tuple[int, ...], str]]] = defaultdict(list)
    for name in port_names:
        parsed = cls.parse_element_name(name)
        if parsed is None:
            continue
        basename, indices = parsed
        groups[basename].append((indices, name))

    aggregates: List[ArrayAggregate] = []
    for basename, elements in groups.items():
        if basename in existing_names:
            continue

        rank = len(elements[0][0])
        if not all(len(idx) == rank for idx, _ in elements):
            if log_prefix:
                logger.debug(f"'{log_prefix}': mixed ranks for array '{basename}', "
                             f"aggregate not created.")
            continue

        mins = [min(idx[a] for idx, _ in elements) for a in range(rank)]
        maxs = [max(idx[a] for idx, _ in elements) for a in range(rank)]
        if not all(s in (0, 1) for s in mins):
            continue
        dims = tuple(maxs[a] - mins[a] + 1 for a in range(rank))

        expected_count = 1
        for d in dims:
            expected_count *= d
        actual_set = {idx for idx, _ in elements}
        if len(actual_set) != len(elements) or expected_count != len(elements):
            if log_prefix:
                logger.debug(f"'{log_prefix}': non-contiguous / duplicated array elements "
                             f"for '{basename}', aggregate not created.")
            continue
        expected_set = set(itertools.product(
            *(range(mins[a], mins[a] + dims[a]) for a in range(rank))))
        if expected_set != actual_set:
            if log_prefix:
                logger.debug(f"'{log_prefix}': non-contiguous array elements for '{basename}', "
                             f"aggregate not created.")
            continue

        # Row-major sort: last index varies fastest.
        elements.sort(key=lambda e: e[0])
        ordered_names = [n for _, n in elements]
        aggregates.append(cls(basename, dims, ordered_names))

    return aggregates

parse_element_name(name) classmethod

Return (basename, indices) if name has the form basename[i,j,...] (Modelica-style comma notation, conforming to FMI-2.0 array-element naming). Returns None if name is not a recognized array element name.

Source code in fmu_manipulation_toolbox/container.py
@classmethod
def parse_element_name(cls, name: str) -> Optional[Tuple[str, Tuple[int, ...]]]:
    """Return `(basename, indices)` if `name` has the form `basename[i,j,...]`
    (Modelica-style comma notation, conforming to FMI-2.0 array-element
    naming). Returns `None` if `name` is not a recognized array element
    name.
    """
    m = cls._ARRAY_ELEM_RE.match(name)
    if not m:
        return None
    basename = m.group(1)
    indices = tuple(int(tok) for tok in m.group(2).split(","))
    return basename, indices

AutoWired

Collects the rules automatically generated by implicit wiring.

Used to report back to the AssemblyNode which inputs, outputs, and links were created by auto-wiring, so they can be recorded in the assembly topology.

Attributes:

Name Type Description
rule_input list[list[str]]

Auto-generated input rules [exposed_name, fmu_name, port_name].

rule_output list[list[str]]

Auto-generated output rules [fmu_name, port_name, exposed_name].

rule_link list[list[str]]

Auto-generated link rules [from_fmu, from_port, to_fmu, to_port].

nb_param int

Number of auto-exposed parameters (subset of inputs).

Source code in fmu_manipulation_toolbox/container.py
class AutoWired:
    """Collects the rules automatically generated by implicit wiring.

    Used to report back to the
    [AssemblyNode][fmu_manipulation_toolbox.assembly.AssemblyNode] which
    inputs, outputs, and links were created by auto-wiring, so they
    can be recorded in the assembly topology.

    Attributes:
        rule_input (list[list[str]]): Auto-generated input rules
            `[exposed_name, fmu_name, port_name]`.
        rule_output (list[list[str]]): Auto-generated output rules
            `[fmu_name, port_name, exposed_name]`.
        rule_link (list[list[str]]): Auto-generated link rules
            `[from_fmu, from_port, to_fmu, to_port]`.
        nb_param (int): Number of auto-exposed parameters (subset of inputs).
    """

    def __init__(self):
        self.rule_input = []
        self.rule_output = []
        self.rule_link = []
        self.nb_param = 0

    def __repr__(self):
        return (f"{self.nb_param} parameters, {len(self.rule_input) - self.nb_param} inputs,"
                f" {len(self.rule_output)} outputs, {len(self.rule_link)} links.")

    def add_input(self, from_port, to_fmu, to_port):
        self.rule_input.append([from_port, to_fmu, to_port])

    def add_parameter(self, from_port, to_fmu, to_port):
        self.rule_input.append([from_port, to_fmu, to_port])
        self.nb_param += 1

    def add_output(self, from_fmu, from_port, to_port):
        self.rule_output.append([from_fmu, from_port, to_port])

    def add_link(self, from_fmu, from_port, to_fmu, to_port):
        self.rule_link.append([from_fmu, from_port, to_fmu, to_port])

ClockList

Tracks clocks that need to be scheduled by the FMI importer.

Used for LS-BUS support where the container runtime needs to trigger countdown clocks on embedded FMUs.

Attributes:

Name Type Description
clocks_per_fmu dict[int, list[tuple[int, int]]]

Clock entries per FMU index: (fmu_vr, local_vr) pairs.

fmu_index dict[str, int]

Mapping from FMU name to its index in the container.

Source code in fmu_manipulation_toolbox/container.py
class ClockList:
    """Tracks clocks that need to be scheduled by the FMI importer.

    Used for LS-BUS support where the container runtime needs to trigger
    countdown clocks on embedded FMUs.

    Attributes:
        clocks_per_fmu (dict[int, list[tuple[int, int]]]): Clock entries
            per FMU index: `(fmu_vr, local_vr)` pairs.
        fmu_index (dict[str, int]): Mapping from FMU name to its index
            in the container.
    """

    def __init__(self, involved_fmu: OrderedDict[str, EmbeddedFMU]):
        self.clocks_per_fmu: DefaultDict[int, List[Clock]] = defaultdict(list)
        self.fmu_index: Dict[str, int] = {}
        for i, fmu_name in enumerate(involved_fmu):
            self.fmu_index[fmu_name] = i

    def append(self, cport: ContainerPort, vr: int):
        """Register a clock for importer scheduling.

        Args:
            cport (ContainerPort): The clocked port on the embedded FMU.
            vr (int): The local value reference of the clock.
        """
        self.clocks_per_fmu[self.fmu_index[cport.fmu.name]].append(Clock(cport.port.vr, vr))

    def write_txt(self, txt_file: IO) -> None:
        """Write the clock scheduling table to the `container.txt` file.

        Args:
            txt_file (IO): Writable text file handle.
        """
        print(f"# importer CLOCKS: <FMU_INDEX> <NB> <FMU_VR> <VR> [<FMU_VR> <VR>]", file=txt_file)
        nb_total_clocks = 0
        for clocks in self.clocks_per_fmu.values():
            nb_total_clocks += len(clocks)

        print(f"{len(self.clocks_per_fmu)} {nb_total_clocks}", file=txt_file)
        for index, clocks in self.clocks_per_fmu.items():
            clocks_str = " ".join([f"{clock.container_vr} {clock.fmu_vr}" for clock in clocks])
            print(f"{index} {len(clocks)} {clocks_str}", file=txt_file)

append(cport, vr)

Register a clock for importer scheduling.

Parameters:

Name Type Description Default
cport ContainerPort

The clocked port on the embedded FMU.

required
vr int

The local value reference of the clock.

required
Source code in fmu_manipulation_toolbox/container.py
def append(self, cport: ContainerPort, vr: int):
    """Register a clock for importer scheduling.

    Args:
        cport (ContainerPort): The clocked port on the embedded FMU.
        vr (int): The local value reference of the clock.
    """
    self.clocks_per_fmu[self.fmu_index[cport.fmu.name]].append(Clock(cport.port.vr, vr))

write_txt(txt_file)

Write the clock scheduling table to the container.txt file.

Parameters:

Name Type Description Default
txt_file IO

Writable text file handle.

required
Source code in fmu_manipulation_toolbox/container.py
def write_txt(self, txt_file: IO) -> None:
    """Write the clock scheduling table to the `container.txt` file.

    Args:
        txt_file (IO): Writable text file handle.
    """
    print(f"# importer CLOCKS: <FMU_INDEX> <NB> <FMU_VR> <VR> [<FMU_VR> <VR>]", file=txt_file)
    nb_total_clocks = 0
    for clocks in self.clocks_per_fmu.values():
        nb_total_clocks += len(clocks)

    print(f"{len(self.clocks_per_fmu)} {nb_total_clocks}", file=txt_file)
    for index, clocks in self.clocks_per_fmu.items():
        clocks_str = " ".join([f"{clock.container_vr} {clock.fmu_vr}" for clock in clocks])
        print(f"{index} {len(clocks)} {clocks_str}", file=txt_file)

ContainerInput

Represents an input port exposed by the container.

A single container input can fan out to multiple embedded FMU input ports, provided they share the same type and causality.

Attributes:

Name Type Description
name str

Exposed name of the container input.

type_name str

Container-internal type name (e.g. "real64").

causality str

Port causality ("input" or "parameter").

cport_list list[ContainerPort]

List of embedded FMU ports connected to this input.

vr int | None

Value reference assigned by the container.

Source code in fmu_manipulation_toolbox/container.py
class ContainerInput:
    """Represents an input port exposed by the container.

    A single container input can fan out to multiple embedded FMU input ports,
    provided they share the same type and causality.

    Attributes:
        name (str): Exposed name of the container input.
        type_name (str): Container-internal type name (e.g. `"real64"`).
        causality (str): Port causality (`"input"` or `"parameter"`).
        cport_list (list[ContainerPort]): List of embedded FMU ports connected
            to this input.
        vr (int | None): Value reference assigned by the container.
    """

    def __init__(self, name: str, cport_to: ContainerPort):
        self.name = name
        self.type_name = cport_to.port.type_name
        self.causality = cport_to.port.causality
        self.cport_list = [cport_to]
        self.vr = None
        self.size = cport_to.port.size()

    def add_cport(self, cport_to: ContainerPort):
        """Connect an additional embedded FMU port to this container input.

        Args:
            cport_to (ContainerPort): The embedded FMU port to connect.

        Raises:
            FMUContainerError: If the port is already connected, or if types
                or causalities do not match.
        """
        if cport_to in self.cport_list: # Cannot be reached ! (Assembly prevent this to happen)
            raise FMUContainerError(f"Duplicate INPUT {cport_to} already connected to {self.name}")

        if cport_to.port.type_name != self.type_name:
            raise FMUContainerError(f"Cannot connect {self.name} of type {self.type_name} to "
                                    f"{cport_to} of type {cport_to.port.type_name}")

        if cport_to.port.size() != self.size:
            raise FMUContainerError(f"Cannot connect {self.name} with dimension {self.size} to "
                                    f"{cport_to} with dimension {cport_to.port.size()}")

        if cport_to.port.causality != self.causality:
            raise FMUContainerError(f"Cannot connect {self.causality.upper()} {self.name} to "
                                    f"{cport_to.port.causality.upper()} {cport_to}")

        self.cport_list.append(cport_to)

add_cport(cport_to)

Connect an additional embedded FMU port to this container input.

Parameters:

Name Type Description Default
cport_to ContainerPort

The embedded FMU port to connect.

required

Raises:

Type Description
FMUContainerError

If the port is already connected, or if types or causalities do not match.

Source code in fmu_manipulation_toolbox/container.py
def add_cport(self, cport_to: ContainerPort):
    """Connect an additional embedded FMU port to this container input.

    Args:
        cport_to (ContainerPort): The embedded FMU port to connect.

    Raises:
        FMUContainerError: If the port is already connected, or if types
            or causalities do not match.
    """
    if cport_to in self.cport_list: # Cannot be reached ! (Assembly prevent this to happen)
        raise FMUContainerError(f"Duplicate INPUT {cport_to} already connected to {self.name}")

    if cport_to.port.type_name != self.type_name:
        raise FMUContainerError(f"Cannot connect {self.name} of type {self.type_name} to "
                                f"{cport_to} of type {cport_to.port.type_name}")

    if cport_to.port.size() != self.size:
        raise FMUContainerError(f"Cannot connect {self.name} with dimension {self.size} to "
                                f"{cport_to} with dimension {cport_to.port.size()}")

    if cport_to.port.causality != self.causality:
        raise FMUContainerError(f"Cannot connect {self.causality.upper()} {self.name} to "
                                f"{cport_to.port.causality.upper()} {cport_to}")

    self.cport_list.append(cport_to)

ContainerPort

References a specific port of an embedded FMU within a container.

Wraps an EmbeddedFMUPort together with its parent EmbeddedFMU, and tracks the value reference assigned by the container.

Attributes:

Name Type Description
fmu EmbeddedFMU

The embedded FMU owning this port.

port EmbeddedFMUPort

The port descriptor.

vr int | None

Value reference assigned by the container, or None if not yet assigned.

Raises:

Type Description
FMUContainerError

If the port name does not exist in the FMU.

Source code in fmu_manipulation_toolbox/container.py
class ContainerPort:
    """References a specific port of an embedded FMU within a container.

    Wraps an [EmbeddedFMUPort][fmu_manipulation_toolbox.container.EmbeddedFMUPort]
    together with its parent
    [EmbeddedFMU][fmu_manipulation_toolbox.container.EmbeddedFMU], and tracks
    the value reference assigned by the container.

    Attributes:
        fmu (EmbeddedFMU): The embedded FMU owning this port.
        port (EmbeddedFMUPort): The port descriptor.
        vr (int | None): Value reference assigned by the container, or `None`
            if not yet assigned.

    Raises:
        FMUContainerError: If the port name does not exist in the FMU.
    """

    def __init__(self, fmu: EmbeddedFMU, port_name: str):
        self.fmu = fmu
        try:
            self.port = fmu.ports[port_name]
        except KeyError:
            raise FMUContainerError(f"Port '{fmu.name}/{port_name}' does not exist")
        self.vr = None

    def __repr__(self):
        return f"Port {self.fmu.name}/{self.port.name}"

    def __hash__(self):
        return hash(str(self))

    def __eq__(self, other):
        return str(self) == str(other)

EmbeddedFMU

Bases: OperationAbstract

Represents an FMU loaded and analyzed for embedding inside a container.

Parses the modelDescription.xml of an FMU to extract its ports, capabilities, step size, platform support, and co-simulation metadata. Implements OperationAbstract to process the FMU descriptor via the visitor pattern.

Attributes:

Name Type Description
capability_list tuple[str, ...]

FMI capability flags tracked by the container.

fmu FMU

The underlying FMU object.

name str

Filename of the FMU (e.g. "model.fmu").

id str

Lowercase stem of the filename, used as an identifier.

terminals Terminals

FMI Terminals defined by this FMU.

ls LayeredStandard

LS-BUS layered standard information.

step_size float | None

Preferred step size in seconds, or None.

start_time float | None

Default experiment start time.

stop_time float | None

Default experiment stop time.

model_identifier str | None

Co-simulation model identifier.

guid str | None

GUID (FMI 2.0) or instantiation token (FMI 3.0).

fmi_version int | None

FMI version (2 or 3).

platforms set[str]

Supported operating systems (e.g. {"Windows", "Linux"}).

ports dict[str, EmbeddedFMUPort]

Ports of the FMU, keyed by name.

has_event_mode bool

Whether the FMU supports event mode (FMI 3.0).

capabilities dict[str, str]

FMI capability flags and their values.

Raises:

Type Description
FMUContainerError

If the FMU does not implement Co-Simulation mode.

Source code in fmu_manipulation_toolbox/container.py
class EmbeddedFMU(OperationAbstract):
    """Represents an FMU loaded and analyzed for embedding inside a container.

    Parses the `modelDescription.xml` of an FMU to extract its ports,
    capabilities, step size, platform support, and co-simulation metadata.
    Implements
    [OperationAbstract][fmu_manipulation_toolbox.operations.OperationAbstract]
    to process the FMU descriptor via the visitor pattern.

    Attributes:
        capability_list (tuple[str, ...]): FMI capability flags tracked by the container.
        fmu (FMU): The underlying
            [FMU][fmu_manipulation_toolbox.operations.FMU] object.
        name (str): Filename of the FMU (e.g. `"model.fmu"`).
        id (str): Lowercase stem of the filename, used as an identifier.
        terminals (Terminals): FMI Terminals defined by this FMU.
        ls (LayeredStandard): LS-BUS layered standard information.
        step_size (float | None): Preferred step size in seconds, or `None`.
        start_time (float | None): Default experiment start time.
        stop_time (float | None): Default experiment stop time.
        model_identifier (str | None): Co-simulation model identifier.
        guid (str | None): GUID (FMI 2.0) or instantiation token (FMI 3.0).
        fmi_version (int | None): FMI version (`2` or `3`).
        platforms (set[str]): Supported operating systems (e.g. `{"Windows", "Linux"}`).
        ports (dict[str, EmbeddedFMUPort]): Ports of the FMU, keyed by name.
        has_event_mode (bool): Whether the FMU supports event mode (FMI 3.0).
        capabilities (dict[str, str]): FMI capability flags and their values.

    Raises:
        FMUContainerError: If the FMU does not implement Co-Simulation mode.
    """

    capability_list = ("needsExecutionTool",
                       "canBeInstantiatedOnlyOncePerProcess",
                       "canHandleVariableCommunicationStepSize")

    def __init__(self, filename):
        self.fmu = FMU(filename)
        self.name = Path(filename).name
        self.id = Path(filename).stem.lower()

        logger.debug(f"Analysing {self.name}")
        self.terminals = Terminals(self.fmu.tmp_directory)
        self.ls = LayeredStandard(self.fmu.tmp_directory)

        self.step_size = None
        self.start_time = None
        self.stop_time = None
        self.model_identifier = None
        self.guid = None
        self.fmi_version = None
        self.platforms = set()
        self.ports: Dict[str, EmbeddedFMUPort] = {}

        self.has_event_mode = False
        self.capabilities: Dict[str, str] = {}
        self.current_port = None  # used during apply_operation()

        self.fmu.apply_operation(self)  # Should be the last command in constructor!
        if self.model_identifier is None:
            raise FMUContainerError(f"FMU '{self.name}' does not implement Co-Simulation mode.")

        if self.fmi_version == 2:
            self._detect_array_aggregates()


    def _detect_array_aggregates(self):
        """Detect FMI-2 array elements notated as `basename[k]` (1D) or
        `basename[i,j,...]` (N-D, Modelica-style comma notation) and expose
        them as a virtual aggregated port named `basename`.

        The aggregate port carries `dimensions=[("start", N0), ("start", N1), ...]`
        and stores the underlying scalar element VRs in `element_vrs`, flattened
        in **row-major** order (last index varies fastest), matching the FMI-3
        array memory layout. This allows the aggregate to be connected to an
        FMI-3 array port of matching shape.
        """
        candidates = ArrayAggregate.detect_all(
            [p.name for p in self.ports.values()],
            existing_names=set(self.ports.keys()),
            log_prefix=self.name,
        )

        for agg in candidates:
            elements = [self.ports[n] for n in agg.ordered_element_names]
            first = elements[0]

            # All elements must share the same type/causality/variability/clock.
            if not all(p.type_name == first.type_name
                       and p.causality == first.causality
                       and p.variability == first.variability
                       and p.clock == first.clock
                       for p in elements):
                logger.debug(f"'{self.name}': array elements for '{agg.basename}' have "
                             f"heterogeneous attributes, aggregate not created.")
                continue

            aggregate = EmbeddedFMUPort(first.type_name, {
                "name": agg.basename,
                "valueReference": first.vr,   # informational; not used for I/O
                "causality": first.causality,
                "variability": first.variability if first.variability else "continuous",
                "description": f"FMI-2 array aggregate of {agg.size} elements '{agg.basename}[]'",
            })
            aggregate.dimensions = [("start", d) for d in agg.dims]
            aggregate.is_fmi2_aggregate = True
            aggregate.element_names = [p.name for p in elements]
            aggregate.clock = first.clock
            self.ports[agg.basename] = aggregate
            logger.debug(f"'{self.name}': aggregated FMI-2 array '{agg.basename}' "
                         f"(shape={agg.shape_str}, {agg.size} elements).")

    def fmi_attrs(self, attrs):
        fmi_version = attrs['fmiVersion']
        if fmi_version == "2.0":
            self.guid = attrs['guid']
            self.fmi_version = 2
        if fmi_version.startswith("3."):
            self.guid = attrs['instantiationToken']
            self.fmi_version = 3

    def cosimulation_attrs(self, attrs: Dict[str, str]):
        self.model_identifier = attrs['modelIdentifier']
        if attrs.get("hasEventMode", "false") == "true":
            self.has_event_mode = True
        for capability in self.capability_list:
            self.capabilities[capability] = attrs.get(capability, "false")

    def experiment_attrs(self, attrs: Dict[str, str]):
        try:
            self.step_size = float(attrs['stepSize'])
        except KeyError:
            logger.warning(f"FMU '{self.name}' does not specify preferred step size")
        self.start_time = float(attrs.get("startTime", 0.0))
        self.stop_time = float(attrs.get("stopTime", self.start_time + 1.0))

    def port_attrs(self, fmu_port: FMUPort):
        # Container will manage Enumeration as Integer
        if fmu_port.fmi_type == "Enumeration":
            if self.fmi_version == 2:
                fmu_port.fmi_type = "Integer"
            else:
                fmu_port.fmi_type = "Int32"
        port = EmbeddedFMUPort(fmu_port.fmi_type, fmu_port, fmi_version=self.fmi_version)
        self.ports[port.name] = port

    def closure(self):
        osname = {
            "win64": "Windows",
            "linux64": "Linux",
            "darwin64": "Darwin",
            "x86_64-windows": "Windows",
            "x86_64-linux": "Linux",
            "aarch64-darwin": "Darwin"
        }
        try:
            for directory in (Path(self.fmu.tmp_directory) / "binaries").iterdir():
                if directory.is_dir() and str(directory.stem) in osname:
                    self.platforms.add(osname[str(directory.stem)])
        except FileNotFoundError:
            pass  # no binaries

    def __repr__(self):
        properties = f"{len(self.ports)} variables, ts={self.step_size}s"
        if len(self.terminals) > 0:
            properties += f", {len(self.terminals)} terminals"
        if len(self.ls) > 0:
            properties += f", {self.ls}"
        return f"'{self.name}' ({properties})"

EmbeddedFMUPort

Represents a port of an FMU embedded inside a container.

Handles the mapping between FMI-standard type names (e.g. Real, Float64) and internal container type names (e.g. real64), and generates the corresponding XML fragments for modelDescription.xml.

Attributes:

Name Type Description
FMI_TO_CONTAINER dict[int, dict[str, str]]

Mapping from FMI type names to container type names, keyed by FMI version.

CONTAINER_TO_FMI dict[int, dict[str, str]]

Reverse mapping from container type names to FMI type names, keyed by FMI version.

ALL_TYPES tuple[str, ...]

All container type names.

causality str

Port causality ("input", "output", "local", "parameter").

variability str | None

Port variability ("continuous", "discrete", etc.).

name str

Port name.

vr int

Value reference in the original FMU.

type_name str

Container-internal type name (e.g. "real64").

start_value str | None

Start value, if defined.

initial str | None

Initial value attribute.

clock str | None

Clock reference for clocked ports.

description str | None

Human-readable description of the port.

Source code in fmu_manipulation_toolbox/container.py
class EmbeddedFMUPort:
    """Represents a port of an FMU embedded inside a container.

    Handles the mapping between FMI-standard type names (e.g. `Real`, `Float64`)
    and internal container type names (e.g. `real64`), and generates the
    corresponding XML fragments for `modelDescription.xml`.

    Attributes:
        FMI_TO_CONTAINER (dict[int, dict[str, str]]): Mapping from FMI type names
            to container type names, keyed by FMI version.
        CONTAINER_TO_FMI (dict[int, dict[str, str]]): Reverse mapping from container
            type names to FMI type names, keyed by FMI version.
        ALL_TYPES (tuple[str, ...]): All container type names.
        causality (str): Port causality (`"input"`, `"output"`, `"local"`,
            `"parameter"`).
        variability (str | None): Port variability (`"continuous"`, `"discrete"`, etc.).
        name (str): Port name.
        vr (int): Value reference in the original FMU.
        type_name (str): Container-internal type name (e.g. `"real64"`).
        start_value (str | None): Start value, if defined.
        initial (str | None): Initial value attribute.
        clock (str | None): Clock reference for clocked ports.
        description (str | None): Human-readable description of the port.
    """

    FMI_TO_CONTAINER = {
        2: {
            'Real': 'real64',
            'Integer': 'integer32',
            'String': 'string',
            'Boolean': 'boolean'
        },
        3: {
            'Float64': 'real64',
            'Float32': 'real32',
            'Int8': 'integer8',
            'UInt8': 'uinteger8',
            'Int16': 'integer16',
            'UInt16': 'uinteger16',
            'Int32': 'integer32',
            'UInt32': 'uinteger32',
            'Int64': 'integer64',
            'UInt64': 'uinteger64',
            'String': 'string',
            'Boolean': 'boolean1',
            'Binary': 'binary',
            'Clock': 'clock'
        }
    }

    CONTAINER_TO_FMI = {
        2: {
            'real64': 'Real',
            'integer32': 'Integer',
            'string': 'String',
            'boolean': 'Boolean'
        },
        3: {
            'real64': 'Float64' ,
            'real32': 'Float32' ,
            'integer8': 'Int8' ,
            'uinteger8': 'UInt8' ,
            'integer16': 'Int16' ,
            'uinteger16': 'UInt16' ,
            'integer32': 'Int32' ,
            'uinteger32': 'UInt32' ,
            'integer64': 'Int64' ,
            'uinteger64': 'UInt64' ,
            'string': 'String' ,
            'boolean1': 'Boolean',
            'binary': 'Binary',
            'clock': 'Clock'
        }
    }

    ALL_TYPES = (
        "real64", "real32",
        "integer8", "uinteger8", "integer16", "uinteger16", "integer32", "uinteger32", "integer64", "uinteger64",
        "boolean", "boolean1",
        "string",
        "binary", "clock"
    )

    def __init__(self, fmi_type, attrs: Union[FMUPort, Dict[str, str]], fmi_version=0):
        self.causality = attrs.get("causality", "local")
        self.variability = attrs.get("variability", None)
        self.interval_variability = attrs.get("intervalVariability", None)
        self.name = attrs["name"]
        self.vr = int(attrs["valueReference"])
        self.description = attrs.get("description", None)
        if isinstance(attrs, FMUPort):
            self.dimensions = attrs.dimensions
        else:
            self.dimensions = []

        # For FMI-2 aggregated arrays: flag marking this port as a virtual
        # array built from scalar element ports (vs a native FMI-3 array port).
        # Used by `xml()` to prevent incorrectly exposing such an aggregate as a
        # container port in an FMI-2 container.
        self.is_fmi2_aggregate: bool = False
        # Names of the underlying scalar element ports (e.g. `basename[1]`, ...),
        # used to mark each individual scalar port as LINK when the aggregate is
        # connected, so the checker does not report them as unconnected.
        self.element_names: List[str] = []

        if fmi_version > 0:
            self.type_name = self.FMI_TO_CONTAINER[fmi_version][fmi_type]
        else:
            self.type_name = fmi_type

        self.start_value = attrs.get("start", None)
        self.initial = attrs.get("initial", None)
        self.clock = attrs.get("clocks", None)

    def size(self) -> int:
        size = 1
        for dimension in self.dimensions:
            if dimension[0] == "start":
                size *= dimension[1]
            else:
                raise FMUError(f"Port '{self.name}' depends on structuralParameter '{dimension[1]}' "
                               f"which is not supported")
        return size

    def xml(self, vr: int, name=None, causality=None, start=None, fmi_version=2) -> str:
        """Generate the XML element for this port in `modelDescription.xml`.

        Produces a `<ScalarVariable>` element (FMI 2.0) or a typed element
        like `<Float64>` (FMI 3.0).

        Args:
            vr (int): Value reference to use in the generated XML.
            name (str | None): Override port name. Defaults to `self.name`.
            causality (str | None): Override causality. Defaults to `self.causality`.
            start (str | None): Override start value. Defaults to `self.start_value`.
            fmi_version (int): FMI version (`2` or `3`).

        Returns:
            str: XML fragment string, or an empty string if the type is not
                compatible with the requested FMI version.
        """
        if name is None:
            name = self.name
        if causality is None:
            causality = self.causality
        if start is None:
            start = self.start_value
            if start is None and self.type_name == "binary" and self.initial == "exact":
                start = ""
        if self.variability is None:
            if self.causality == "parameter":
                self.variability = "fixed"
            else:
                self.variability = "continuous" if "real" in self.type_name else "discrete"

        try:
            fmi_type = self.CONTAINER_TO_FMI[fmi_version][self.type_name]
        except KeyError:
            logger.error(f"Cannot expose ({causality}) '{name}' because type '{self.type_name}' is not compatible "
                         f"with FMI-{fmi_version}.0")
            return ""

        if fmi_version == 2 and self.is_fmi2_aggregate:
            logger.error(f"Cannot expose FMI-2 array aggregate '{name}' in an FMI-2 container "
                         f"(use the scalar elements '{name}[k]' individually).")
            return ""

        if fmi_version == 2:
            child_attrs =  {
                "start": start,
            }

            filtered_child_attrs = {key: value for key, value in child_attrs.items() if value is not None}
            child_str = (f"<{fmi_type} " +
                         " ".join([f'{key}="{value}"' for (key, value) in filtered_child_attrs.items()]) +
                         "/>")

            scalar_attrs = {
                "name": name,
                "valueReference": vr,
                "causality": causality,
                "variability": self.variability,
                "initial": self.initial,
                "description": self.description,
            }
            filtered_attrs = {key: value for key, value in scalar_attrs.items() if value is not None}
            scalar_attrs_str = " ".join([f'{key}="{value}"' for (key, value) in filtered_attrs.items()])
            return f'<ScalarVariable {scalar_attrs_str}>{child_str}</ScalarVariable>'

        elif fmi_version == 3:
            child_str = ""
            for dimension in self.dimensions:
                child_str += f'<Dimension {dimension[0]}="{dimension[1]}"/>'

            if child_str or fmi_type in ('String', 'Binary'):
                if start is not None:
                    child_str += f'<Start value="{start}"/>'

                scalar_attrs = {
                    "name": name,
                    "valueReference": vr,
                    "causality": causality,
                    "variability": self.variability,
                    "initial": self.initial,
                    "description": self.description,
                }
                filtered_attrs = {key: value for key, value in scalar_attrs.items() if value is not None}
                scalar_attrs_str = " ".join([f'{key}="{value}"' for (key, value) in filtered_attrs.items()])
                return f'<{fmi_type} {scalar_attrs_str}>{child_str}</{fmi_type}>'
            else:
                scalar_attrs = {
                    "name": name,
                    "valueReference": vr,
                    "causality": causality,
                    "variability": self.variability,
                    "initial": self.initial,
                    "description": self.description,
                    "start": start,
                    "intervalVariability": self.interval_variability
                }
                filtered_attrs = {key: value for key, value in scalar_attrs.items() if value is not None}
                scalar_attrs_str = " ".join([f'{key}="{value}"' for (key, value) in filtered_attrs.items()])

                return f'<{fmi_type} {scalar_attrs_str}/>'
        else:
            logger.critical(f"Unknown version {fmi_version}. BUG?")
            return ''

xml(vr, name=None, causality=None, start=None, fmi_version=2)

Generate the XML element for this port in modelDescription.xml.

Produces a <ScalarVariable> element (FMI 2.0) or a typed element like <Float64> (FMI 3.0).

Parameters:

Name Type Description Default
vr int

Value reference to use in the generated XML.

required
name str | None

Override port name. Defaults to self.name.

None
causality str | None

Override causality. Defaults to self.causality.

None
start str | None

Override start value. Defaults to self.start_value.

None
fmi_version int

FMI version (2 or 3).

2

Returns:

Name Type Description
str str

XML fragment string, or an empty string if the type is not compatible with the requested FMI version.

Source code in fmu_manipulation_toolbox/container.py
def xml(self, vr: int, name=None, causality=None, start=None, fmi_version=2) -> str:
    """Generate the XML element for this port in `modelDescription.xml`.

    Produces a `<ScalarVariable>` element (FMI 2.0) or a typed element
    like `<Float64>` (FMI 3.0).

    Args:
        vr (int): Value reference to use in the generated XML.
        name (str | None): Override port name. Defaults to `self.name`.
        causality (str | None): Override causality. Defaults to `self.causality`.
        start (str | None): Override start value. Defaults to `self.start_value`.
        fmi_version (int): FMI version (`2` or `3`).

    Returns:
        str: XML fragment string, or an empty string if the type is not
            compatible with the requested FMI version.
    """
    if name is None:
        name = self.name
    if causality is None:
        causality = self.causality
    if start is None:
        start = self.start_value
        if start is None and self.type_name == "binary" and self.initial == "exact":
            start = ""
    if self.variability is None:
        if self.causality == "parameter":
            self.variability = "fixed"
        else:
            self.variability = "continuous" if "real" in self.type_name else "discrete"

    try:
        fmi_type = self.CONTAINER_TO_FMI[fmi_version][self.type_name]
    except KeyError:
        logger.error(f"Cannot expose ({causality}) '{name}' because type '{self.type_name}' is not compatible "
                     f"with FMI-{fmi_version}.0")
        return ""

    if fmi_version == 2 and self.is_fmi2_aggregate:
        logger.error(f"Cannot expose FMI-2 array aggregate '{name}' in an FMI-2 container "
                     f"(use the scalar elements '{name}[k]' individually).")
        return ""

    if fmi_version == 2:
        child_attrs =  {
            "start": start,
        }

        filtered_child_attrs = {key: value for key, value in child_attrs.items() if value is not None}
        child_str = (f"<{fmi_type} " +
                     " ".join([f'{key}="{value}"' for (key, value) in filtered_child_attrs.items()]) +
                     "/>")

        scalar_attrs = {
            "name": name,
            "valueReference": vr,
            "causality": causality,
            "variability": self.variability,
            "initial": self.initial,
            "description": self.description,
        }
        filtered_attrs = {key: value for key, value in scalar_attrs.items() if value is not None}
        scalar_attrs_str = " ".join([f'{key}="{value}"' for (key, value) in filtered_attrs.items()])
        return f'<ScalarVariable {scalar_attrs_str}>{child_str}</ScalarVariable>'

    elif fmi_version == 3:
        child_str = ""
        for dimension in self.dimensions:
            child_str += f'<Dimension {dimension[0]}="{dimension[1]}"/>'

        if child_str or fmi_type in ('String', 'Binary'):
            if start is not None:
                child_str += f'<Start value="{start}"/>'

            scalar_attrs = {
                "name": name,
                "valueReference": vr,
                "causality": causality,
                "variability": self.variability,
                "initial": self.initial,
                "description": self.description,
            }
            filtered_attrs = {key: value for key, value in scalar_attrs.items() if value is not None}
            scalar_attrs_str = " ".join([f'{key}="{value}"' for (key, value) in filtered_attrs.items()])
            return f'<{fmi_type} {scalar_attrs_str}>{child_str}</{fmi_type}>'
        else:
            scalar_attrs = {
                "name": name,
                "valueReference": vr,
                "causality": causality,
                "variability": self.variability,
                "initial": self.initial,
                "description": self.description,
                "start": start,
                "intervalVariability": self.interval_variability
            }
            filtered_attrs = {key: value for key, value in scalar_attrs.items() if value is not None}
            scalar_attrs_str = " ".join([f'{key}="{value}"' for (key, value) in filtered_attrs.items()])

            return f'<{fmi_type} {scalar_attrs_str}/>'
    else:
        logger.critical(f"Unknown version {fmi_version}. BUG?")
        return ''

FMUContainer

Builds an FMU Container that embeds multiple FMUs into a single FMU.

An FMUContainer acts as both an FMI co-simulation FMU and an FMI importer. It loads embedded FMUs, wires their ports together, and generates the modelDescription.xml, the runtime configuration (container.txt), and the final .fmu archive.

Examples:

from pathlib import Path
from fmu_manipulation_toolbox.container import FMUContainer

container = FMUContainer("bouncing", Path("fmus"), fmi_version=2)
container.get_fmu("bb_position.fmu")
container.get_fmu("bb_velocity.fmu")
container.add_link("bb_position.fmu", "is_ground",
                   "bb_velocity.fmu", "reset")
container.add_implicit_rule(auto_input=True, auto_output=True)
container.make_fmu("bouncing.fmu", step_size=0.1)

Attributes:

Name Type Description
fmu_directory Path

Directory containing the source FMUs.

identifier str

Model identifier for the container.

fmi_version int

FMI version of the container interface (2 or 3).

involved_fmu OrderedDict[str, EmbeddedFMU]

Embedded FMUs, keyed by filename, in insertion order.

inputs dict[str, ContainerInput]

Container input ports, keyed by exposed name.

outputs dict[str, ContainerPort]

Container output ports, keyed by exposed name.

links dict[ContainerPort, Link]

Internal links between embedded FMUs.

start_values dict[ContainerPort, str]

Start values for embedded FMU ports.

vr_table ValueReferenceTable

Value reference allocator.

Raises:

Type Description
FMUContainerError

If the FMU directory is invalid.

Source code in fmu_manipulation_toolbox/container.py
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
class FMUContainer:
    """Builds an FMU Container that embeds multiple FMUs into a single FMU.

    An `FMUContainer` acts as both an FMI co-simulation FMU and an FMI importer.
    It loads embedded FMUs, wires their ports together, and generates the
    `modelDescription.xml`, the runtime configuration (`container.txt`), and the
    final `.fmu` archive.

    Examples:
        ```python
        from pathlib import Path
        from fmu_manipulation_toolbox.container import FMUContainer

        container = FMUContainer("bouncing", Path("fmus"), fmi_version=2)
        container.get_fmu("bb_position.fmu")
        container.get_fmu("bb_velocity.fmu")
        container.add_link("bb_position.fmu", "is_ground",
                           "bb_velocity.fmu", "reset")
        container.add_implicit_rule(auto_input=True, auto_output=True)
        container.make_fmu("bouncing.fmu", step_size=0.1)
        ```

    Attributes:
        fmu_directory (Path): Directory containing the source FMUs.
        identifier (str): Model identifier for the container.
        fmi_version (int): FMI version of the container interface (`2` or `3`).
        involved_fmu (OrderedDict[str, EmbeddedFMU]): Embedded FMUs, keyed
            by filename, in insertion order.
        inputs (dict[str, ContainerInput]): Container input ports, keyed by
            exposed name.
        outputs (dict[str, ContainerPort]): Container output ports, keyed by
            exposed name.
        links (dict[ContainerPort, Link]): Internal links between embedded FMUs.
        start_values (dict[ContainerPort, str]): Start values for embedded FMU ports.
        vr_table (ValueReferenceTable): Value reference allocator.

    Raises:
        FMUContainerError: If the FMU directory is invalid.
    """

    HEADER_XML_2 = """<?xml version="1.0" encoding="ISO-8859-1"?>
<fmiModelDescription
  fmiVersion="2.0"
  modelName="{identifier}"
  generationTool="FMUContainer-{tool_version}"
  generationDateAndTime="{timestamp}"
  guid="{guid}"
  description="FMUContainer with {embedded_fmu}"
  author="{author}"
  license="Proprietary"
  copyright="See Embedded FMU's copyrights."
  variableNamingConvention="structured">

  <CoSimulation
    modelIdentifier="{identifier}"
    canHandleVariableCommunicationStepSize="true"
    canBeInstantiatedOnlyOncePerProcess="{only_once}"
    canNotUseMemoryManagementFunctions="true"
    canGetAndSetFMUstate="false"
    canSerializeFMUstate="false"
    providesDirectionalDerivative="false"
    needsExecutionTool="{execution_tool}">
  </CoSimulation>

  <LogCategories>
    <Category name="Info"
              description="Info log messages." />
    <Category name="Error"
              description="Error log messages." />
  </LogCategories>

  <DefaultExperiment stepSize="{step_size}"{default_experiment_times}/>

  <ModelVariables>
    <ScalarVariable valueReference="0" name="time" causality="independent"><Real /></ScalarVariable>
"""

    HEADER_XML_3 = """<?xml version="1.0" encoding="ISO-8859-1"?>
<fmiModelDescription
  fmiVersion="3.0"
  modelName="{identifier}"
  generationTool="FMUContainer-{tool_version}"
  generationDateAndTime="{timestamp}"
  instantiationToken="{guid}"
  description="FMUContainer with {embedded_fmu}"
  author="{author}"
  license="Proprietary"
  copyright="See Embedded FMU's copyrights."
  variableNamingConvention="structured">

  <CoSimulation
    modelIdentifier="{identifier}"
    canHandleVariableCommunicationStepSize="true"
    canBeInstantiatedOnlyOncePerProcess="{only_once}"
    canNotUseMemoryManagementFunctions="true"
    canGetAndSetFMUState="false"
    canSerializeFMUState="false"
    providesDirectionalDerivatives="false"
    providesAdjointDerivatives="false"
    providesPerElementDependencies="false"
    providesEvaluateDiscreteStates="false"
    hasEventMode="false"
    needsExecutionTool="{execution_tool}">
  </CoSimulation>

  <LogCategories>
    <Category name="Info"
              description="Info log messages." />
    <Category name="Error"
              description="Error log messages." />
  </LogCategories>

  <DefaultExperiment stepSize="{step_size}"{default_experiment_times}/>

  <ModelVariables>
    <Float64 valueReference="0" name="time" causality="independent"/>
"""

    def __init__(self, identifier: str, fmu_directory: Union[str, Path], description_pathname=None, fmi_version=2):
        self.fmu_directory = Path(fmu_directory)
        self.identifier = identifier
        if not self.fmu_directory.is_dir():
            raise FMUContainerError(f"{self.fmu_directory} is not a valid directory")
        self.involved_fmu: OrderedDict[str, EmbeddedFMU] = OrderedDict()

        self.description_pathname = description_pathname
        self.fmi_version = fmi_version

        self.start_time = None
        self.stop_time = None

        # Rules
        self.inputs: Dict[str, ContainerInput] = {}
        self.outputs: Dict[str, ContainerPort] = {}
        self.links: Dict[ContainerPort, Link] = {}

        self.rules: Dict[ContainerPort, str] = {}
        self.start_values: Dict[ContainerPort, str] = {}

        self.vr_table = ValueReferenceTable()

    def get_fmu(self, fmu_filename: str) -> EmbeddedFMU:
        """Load an embedded FMU from the FMU directory.

        If the FMU has already been loaded, returns the cached instance.

        Args:
            fmu_filename (str): Filename of the FMU (e.g. `"model.fmu"`).

        Returns:
            EmbeddedFMU: The loaded and analysed FMU.

        Raises:
            FMUContainerError: If the FMU cannot be loaded.
        """
        fmu_name = Path(fmu_filename).name
        if fmu_name in self.involved_fmu:
            return self.involved_fmu[fmu_name]

        try:
            fmu = EmbeddedFMU(self.fmu_directory / fmu_filename)
            if not fmu.fmi_version == self.fmi_version:
                logger.warning(f"Try to embed FMU-{fmu.fmi_version} into container FMI-{self.fmi_version}.")
            self.involved_fmu[fmu.name] = fmu

            logger.info(f"Involved FMU #{len(self.involved_fmu)}: {fmu}")
        except (FMUContainerError, FMUError) as e:
            raise FMUContainerError(f"Cannot load '{fmu_filename}': {e}")

        return fmu

    def mark_ruled(self, cport: ContainerPort, rule: str):
        if cport in self.rules:
            previous_rule = self.rules[cport]
            if rule not in ("OUTPUT", "LINK") and previous_rule not in ("OUTPUT", "LINK"):
                raise FMUContainerError(f"try to {rule} port {cport} which is already {previous_rule}")

        self.rules[cport] = rule

    def get_all_cports(self):
        cport_list = []
        for fmu in self.involved_fmu.values():
            for port_name in fmu.ports:
                try:
                    cport = ContainerPort(fmu, port_name)
                    cport_list.append(cport)
                except FMUContainerError:
                    pass

        return cport_list

    def add_input(self, container_port_name: str, to_fmu_filename: str, to_port_name: str):
        """Expose a port of an embedded FMU as a container input.

        Multiple embedded FMU ports can be connected to the same container
        input (fan-out), provided they share the same type and causality.

        Args:
            container_port_name (str): Exposed name on the container. If empty,
                defaults to `to_port_name`.
            to_fmu_filename (str): Filename of the embedded FMU.
            to_port_name (str): Name of the input port on the embedded FMU.

        Raises:
            FMUContainerError: If the port causality is not `"input"` or
                `"parameter"`, or if types do not match an existing input
                with the same name.
        """
        if not container_port_name:
            container_port_name = to_port_name

        try:
            cport_to = ContainerPort(self.get_fmu(to_fmu_filename), to_port_name)
        except FMUContainerError as e:
            logger.error(f"Cannot add input: {e}")
            return

        if cport_to.port.causality not in ("input", "parameter"):  # check causality
            raise FMUContainerError(f"Tried to use '{cport_to}' as INPUT of the container but FMU causality is "
                                    f"'{cport_to.port.causality}'.")

        try:
            input_port = self.inputs[container_port_name]
            input_port.add_cport(cport_to)
        except KeyError:
            self.inputs[container_port_name] = ContainerInput(container_port_name, cport_to)

        logger.debug(f"INPUT: {to_fmu_filename}:{to_port_name}")
        self.mark_ruled(cport_to, 'INPUT')

    def add_output(self, from_fmu_filename: str, from_port_name: str, container_port_name: str):
        """Expose a port of an embedded FMU as a container output.

        Args:
            from_fmu_filename (str): Filename of the embedded FMU.
            from_port_name (str): Name of the output port on the embedded FMU.
            container_port_name (str): Exposed name on the container. If empty,
                defaults to `from_port_name`.

        Raises:
            FMUContainerError: If the port causality is not `"output"` or
                `"local"`, or if the exposed name is already used.
        """
        if not container_port_name:  # empty is allowed
            container_port_name = from_port_name

        try:
            cport_from = ContainerPort(self.get_fmu(from_fmu_filename), from_port_name)
        except FMUContainerError as e:
            logger.error(f"Cannot add output: {e}")
            return

        if cport_from.port.causality not in ("output", "local"):  # check causality
            raise FMUContainerError(f"Tried to use '{cport_from}' as OUTPUT of the container but FMU causality is "
                                    f"'{cport_from.port.causality}'.")

        if container_port_name in self.outputs:
            raise FMUContainerError(f"Duplicate OUTPUT {container_port_name} already connected to {cport_from}")

        logger.debug(f"OUTPUT: {from_fmu_filename}:{from_port_name}")
        self.mark_ruled(cport_from, 'OUTPUT')
        self.outputs[container_port_name] = cport_from

    def drop_port(self, from_fmu_filename: str, from_port_name: str):
        """Explicitly ignore an output port of an embedded FMU.

        Prevents the port from being auto-exposed or flagged as unconnected.

        Args:
            from_fmu_filename (str): Filename of the embedded FMU.
            from_port_name (str): Name of the output port to drop.

        Raises:
            FMUContainerError: If the port causality is not `"output"`.
        """

        try:
            cport_from = ContainerPort(self.get_fmu(from_fmu_filename), from_port_name)
        except FMUContainerError as e:
            logger.error(f"Cannot drop port: {e}")
            return

        if not cport_from.port.causality == "output":  # check causality
            raise FMUContainerError(f"{cport_from}: trying to DROP {cport_from.port.causality}")

        logger.debug(f"DROP: {from_fmu_filename}:{from_port_name}")
        self.mark_ruled(cport_from, 'DROP')

    def add_link(self, from_fmu_filename: str, from_port_name: str, to_fmu_filename: str, to_port_name: str):
        """Connect an output of one embedded FMU to an input of another.

        If both port names match FMI Terminal definitions, a terminal-level
        connection is made (connecting all member ports). Otherwise, a regular
        port-to-port link is created.

        Args:
            from_fmu_filename (str): Filename of the source FMU.
            from_port_name (str): Output port name (or terminal name).
            to_fmu_filename (str): Filename of the destination FMU.
            to_port_name (str): Input port name (or terminal name).

        Raises:
            FMUContainerError: If port causalities are invalid or types
                are incompatible.
        """
        fmu_from = self.get_fmu(from_fmu_filename)
        fmu_to = self.get_fmu(to_fmu_filename)

        if from_port_name in fmu_from.terminals and to_port_name in fmu_to.terminals:
            # TERMINAL Connection
            terminal1 = fmu_from.terminals[from_port_name]
            terminal2 = fmu_to.terminals[to_port_name]
            if terminal1 == terminal2:
                logger.debug(f"Plugging terminals: {terminal1} <-> {terminal2}")
                for terminal1_port_name, terminal2_port_name in terminal1.connect(terminal2):
                    self.add_link_regular(fmu_from, terminal1_port_name, fmu_to, terminal2_port_name)
            else:
                logger.error(f"Cannot plug incompatible terminals: {terminal1} <-> {terminal2}")
        else:
            # REGULAR port connection
            self.add_link_regular(fmu_from, from_port_name, fmu_to, to_port_name)

    def add_link_regular(self, fmu_from: EmbeddedFMU, from_port_name: str, fmu_to: EmbeddedFMU, to_port_name: str):

            try:
                cport_from = ContainerPort(fmu_from, from_port_name)
                cport_to = ContainerPort(fmu_to, to_port_name)
            except FMUContainerError as e:
                logger.error(f"Cannot link {from_port_name} -> {to_port_name}: {e}")
                return

            if cport_to.port.causality == "output" and cport_from.port.causality == "input":
                logger.debug("Invert link orientation")
                tmp = cport_to
                cport_to = cport_from
                cport_from = tmp

            try:
                local = self.links[cport_from]
            except KeyError:
                local = Link(cport_from)
                self.links[cport_from] = local

            local.add_target(cport_to)  # Causality is check in the add() function

            logger.debug(f"LINK: {cport_from} -> {cport_to}")
            self.mark_ruled(cport_from, 'LINK')
            self.mark_ruled(cport_to, 'LINK')

            # If either side is an FMI-2 array aggregate, also mark each
            # underlying scalar element port as LINK so it is not reported
            # as unconnected.
            for cport in (cport_from, cport_to):
                for elt_name in cport.port.element_names:
                    try:
                        self.mark_ruled(ContainerPort(cport.fmu, elt_name), 'LINK')
                    except FMUContainerError:
                        pass

    def add_start_value(self, fmu_filename: str, port_name: str, value: str):
        """Set a start value for a port of an embedded FMU.

        The value is automatically converted to the appropriate type
        (float, int, bool, or string).

        Args:
            fmu_filename (str): Filename of the embedded FMU.
            port_name (str): Name of the port.
            value (str): Start value as a string.

        Raises:
            FMUContainerError: If the value cannot be converted to the
                port's type.
        """

        try:
            cport = ContainerPort(self.get_fmu(fmu_filename), port_name)
        except FMUContainerError as e:
            logger.error(f"Cannot set start value: {e}")
            return

        # Check dimensions
        value_tokens = str(value).split(' ')
        if not len(value_tokens) == cport.port.size():
            raise FMUContainerError(f"Start value missmatch for {cport.port.type_name} which is dimension {cport.port.size()}")

        # Check type
        for token in value_tokens:
            try:
                if cport.port.type_name.startswith('real'):
                    float(token)
                elif cport.port.type_name.startswith('integer') or  cport.port.type_name.startswith('uinteger'):
                    int(token)
                elif cport.port.type_name.startswith('boolean'):
                    if token not in ("true", "false", "0", "1"):
                        raise ValueError(f"Invalid boolean value: '{token}'")
                elif cport.port.type_name == 'string':
                    pass
                else:
                    logger.error(f"Start value cannot be set on '{cport.port.type_name}'")
                    return
            except ValueError:
                raise FMUContainerError(f"Start value is not conforming to {cport.port.type_name} format.")

        # Format is different for string
        if cport.port.type_name == 'string':
            value = "\n" + "\n".join(value_tokens)

        self.start_values[cport] = value

    def find_inputs(self, port_to_connect: EmbeddedFMUPort) -> List[ContainerPort]:
        candidates = []
        for cport in self.get_all_cports():
            if (cport.port.causality == 'input' and cport not in self.rules and cport.port.name == port_to_connect.name
                    and cport.port.type_name == port_to_connect.type_name):
                candidates.append(cport)
        return candidates

    def add_implicit_rule(self, auto_input=True, auto_output=True, auto_link=True, auto_parameter=False,
                          auto_local=False) -> AutoWired:
        """Automatically wire unconnected ports of embedded FMUs.

        Processes all ports in the following order:

        1. **auto_link**: Connect outputs to inputs with matching names and types.
        2. **auto_output**: Expose remaining unconnected outputs.
        3. **auto_local**: Expose local variables.
        4. **auto_input**: Expose remaining unconnected inputs.
        5. **auto_parameter**: Expose parameters.

        Args:
            auto_input (bool): Expose unconnected input ports.
            auto_output (bool): Expose unconnected output ports.
            auto_link (bool): Link matching output/input ports automatically.
            auto_parameter (bool): Expose parameter ports.
            auto_local (bool): Expose local variables.

        Returns:
            AutoWired: Record of all automatically created rules.
        """
        auto_wired = AutoWired()
        # Auto Link outputs
        for cport in self.get_all_cports():
            if cport.port.causality == 'output':
                candidates_cport_list = self.find_inputs(cport.port)
                if auto_link and candidates_cport_list:
                    for candidate_cport in candidates_cport_list:
                        logger.info(f"AUTO LINK: {cport} -> {candidate_cport}")
                        self.add_link(cport.fmu.name, cport.port.name,
                                      candidate_cport.fmu.name, candidate_cport.port.name)
                        auto_wired.add_link(cport.fmu.name, cport.port.name,
                                            candidate_cport.fmu.name, candidate_cport.port.name)
                elif auto_output and cport not in self.rules:
                    logger.info(f"AUTO OUTPUT: Expose {cport}")
                    self.add_output(cport.fmu.name, cport.port.name, cport.port.name)
                    auto_wired.add_output(cport.fmu.name, cport.port.name, cport.port.name)
            elif cport.port.causality == 'local':
                local_portname = None
                if cport.port.name.startswith("container."):
                    local_portname = "container." + cport.fmu.id + "." + cport.port.name[10:]
                    logger.info(f"PROFILING: Expose {cport}")
                elif auto_local:
                    local_portname = cport.fmu.id + "." + cport.port.name
                    logger.info(f"AUTO LOCAL: Expose {cport}")
                if local_portname:
                    self.add_output(cport.fmu.name, cport.port.name, local_portname)
                    auto_wired.add_output(cport.fmu.name, cport.port.name, local_portname)

        # Auto link inputs
        for cport in self.get_all_cports():
            if cport not in self.rules:
                if auto_parameter and cport.port.causality == 'parameter':
                    parameter_name = cport.fmu.id + "." + cport.port.name
                    logger.info(f"AUTO PARAMETER: {cport} as {parameter_name}")
                    self.add_input(parameter_name, cport.fmu.name, cport.port.name)
                    auto_wired.add_parameter(parameter_name, cport.fmu.name, cport.port.name)
                elif auto_input and cport.port.causality == 'input' :
                    logger.info(f"AUTO INPUT: Expose {cport}")
                    self.add_input(cport.port.name, cport.fmu.name, cport.port.name)
                    auto_wired.add_input(cport.port.name, cport.fmu.name, cport.port.name)

        logger.info(f"Auto-wiring: {auto_wired}")

        return auto_wired

    def default_step_size(self) -> float:
        """Compute the default step size from embedded FMUs.

        Uses the GCD of the frequencies of FMUs that cannot handle variable
        step sizes. If all FMUs support variable steps, returns the largest
        step size.

        Returns:
            float: Computed step size in seconds.
        """
        default_step_size = 0.1
        freq_set = set()
        for fmu in self.involved_fmu.values():
            if fmu.step_size and fmu.capabilities["canHandleVariableCommunicationStepSize"] == "false":
                freq_set.add(int(1.0/fmu.step_size))

        if not freq_set:
            # all involved FMUs can Handle Variable Communication StepSize
            try:
                step_size_max = 0
                for fmu in self.involved_fmu.values():
                    if fmu.step_size > step_size_max:
                        step_size_max = fmu.step_size
                return step_size_max
            except TypeError:
                # all involved FMUs do not specify step_size
                logger.warning(f"Defaulting to step_size={default_step_size}")
                step_size = default_step_size
        else:
            common_freq = math.gcd(*freq_set)
            try:
                step_size = 1.0 / float(common_freq)
            except ZeroDivisionError:
                logger.warning(f"Defaulting to step_size={default_step_size}")
                step_size = default_step_size

        return step_size

    def sanity_check(self, step_size: Optional[float]):
        """Validate the container configuration before building.

        Warns about step size mismatches and unconnected ports.

        Args:
            step_size (float | None): The container's internal step size.
        """
        for fmu in self.involved_fmu.values():
            if fmu.step_size and fmu.capabilities["canHandleVariableCommunicationStepSize"] == "false":
                ts_ratio = step_size / fmu.step_size
                logger.debug(f"container step_size: {step_size} = {fmu.step_size} x {ts_ratio} for {fmu.name}")
                if ts_ratio < 1.0:
                    logger.warning(f"Container step_size={step_size}s is lower than FMU '{fmu.name}' "
                                   f"step_size={fmu.step_size}s.")
                if ts_ratio != int(ts_ratio):
                    logger.warning(f"Container step_size={step_size}s should divisible by FMU '{fmu.name}' "
                                   f"step_size={fmu.step_size}s.")
            for port_name in fmu.ports:
                cport = ContainerPort(fmu, port_name)
                if cport not in self.rules:
                    if cport.port.causality == 'input':
                        logger.error(f"Input '{cport}' is not connected")
                    if cport.port.causality == 'output':
                        logger.warning(f"Output '{cport}' is not connected")

    def make_fmu(self, fmu_filename: Union[str, Path], step_size: Optional[float] = None, debug=False, mt=False,
                 profiling=False, sequential=False, ts_multiplier=False, datalog=False):
        """Build the FMU Container archive.

        Generates the `modelDescription.xml`, the `container.txt` runtime
        configuration, and packages everything into a `.fmu` zip archive.

        Args:
            fmu_filename (str | Path): Output filename for the container.
            step_size (float | None): Internal time step in seconds. If `None`,
                deduced from the embedded FMUs.
            debug (bool): Keep intermediate build artifacts.
            mt (bool): Enable multithreaded mode.
            profiling (bool): Enable profiling mode.
            sequential (bool): Use sequential scheduling.
            ts_multiplier (bool): Add a `TS_MULTIPLIER` input port.
            datalog (bool): Generate a datalog configuration.
        """
        if isinstance(fmu_filename, str):
            fmu_filename = Path(fmu_filename)

        if step_size is None:
            logger.info(f"step_size  will be deduced from the embedded FMU's")
            step_size = self.default_step_size()
        self.sanity_check(step_size)

        logger.info(f"Building FMU '{fmu_filename}', step_size={step_size}")

        base_directory = self.fmu_directory / fmu_filename.with_suffix('')
        resources_directory = self.make_fmu_skeleton(base_directory)

        with open(base_directory / "modelDescription.xml", "wt") as xml_file:
            self.make_fmu_xml(xml_file, step_size, profiling, ts_multiplier)
        with open(resources_directory / "container.txt", "wt") as txt_file:
            self.make_fmu_txt(txt_file, step_size, mt, profiling, sequential)

        if datalog:
            with open(resources_directory / "datalog.txt", "wt") as datalog_file:
                self.make_datalog(datalog_file)

        self.make_fmu_package(base_directory, fmu_filename)
        if not debug:
            self.make_fmu_cleanup(base_directory)

    def make_fmu_xml(self, xml_file, step_size: float, profiling: bool, ts_multiplier: bool):
        timestamp = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
        guid = str(uuid.uuid4())
        embedded_fmu = ", ".join([fmu_name for fmu_name in self.involved_fmu])
        try:
            author = getpass.getuser()
        except OSError:
            author = "Unspecified"

        capabilities = {}
        for capability in EmbeddedFMU.capability_list:
            capabilities[capability] = "false"
            for fmu in self.involved_fmu.values():
                if fmu.capabilities[capability] == "true":
                    capabilities[capability] = "true"

        first_fmu = next(iter(self.involved_fmu.values()))
        if self.start_time is None:
            self.start_time = first_fmu.start_time
            logger.info(f"start_time={self.start_time} (deduced from '{first_fmu.name}')")
        else:
            logger.info(f"start_time={self.start_time}")

        if self.stop_time is None:
            self.stop_time = first_fmu.stop_time
            logger.info(f"stop_time={self.stop_time} (deduced from '{first_fmu.name}')")
        else:
            logger.info(f"stop_time={self.stop_time}")

        default_experiment_times = ""
        if self.start_time is not None:
            default_experiment_times += f' startTime="{self.start_time}"'
        if self.stop_time is not None:
            default_experiment_times += f' stopTime="{self.stop_time}"'

        if self.fmi_version == 2:
            xml_file.write(self.HEADER_XML_2.format(identifier=self.identifier, tool_version=tool_version,
                                                    timestamp=timestamp, guid=guid, embedded_fmu=embedded_fmu,
                                                    author=author,
                                                    only_once=capabilities['canBeInstantiatedOnlyOncePerProcess'],
                                                    execution_tool=capabilities['needsExecutionTool'],
                                                    default_experiment_times=default_experiment_times,
                                                    step_size=step_size))
        elif self.fmi_version == 3:
            xml_file.write(self.HEADER_XML_3.format(identifier=self.identifier, tool_version=tool_version,
                                                    timestamp=timestamp, guid=guid, embedded_fmu=embedded_fmu,
                                                    author=author,
                                                    only_once=capabilities['canBeInstantiatedOnlyOncePerProcess'],
                                                    execution_tool=capabilities['needsExecutionTool'],
                                                    default_experiment_times=default_experiment_times,
                                                    step_size=step_size))

        vr_time = self.vr_table.add_vr("real64", local=True)
        logger.debug(f"Time vr = {vr_time}")

        vr_ts_multiplier = self.vr_table.add_vr("integer32", local=True)
        if ts_multiplier:
            logger.debug(f"TS Multiplier vr = {vr_ts_multiplier}")
            port = EmbeddedFMUPort("integer32", {"valueReference": vr_ts_multiplier,
                                                 "name": f"container.ts_multiplier",
                                                 "causality": "input",
                                                 "description": f"Timestep multiplier",
                                                 "variability": "discrete",
                                                 "start": 1,
                                                 "initial": "exact"})
            print(f"    {port.xml(vr_ts_multiplier, fmi_version=self.fmi_version)}", file=xml_file)

        if profiling:
            for fmu in self.involved_fmu.values():
                vr = self.vr_table.add_vr("real64", local=True)
                port = EmbeddedFMUPort("real64", {"valueReference": vr,
                                        "name": f"container.{fmu.id}.rt_ratio",
                                        "description": f"RT ratio for embedded FMU '{fmu.name}'"})
                print(f"    {port.xml(vr, fmi_version=self.fmi_version)}", file=xml_file)

        index_offset = 2    # index of output ports. Start at 2 to skip "time" port

        # Local variable should be first to ensure to attribute them the lowest VR.
        nb_clocks = 0
        for link in self.links.values():
            self.vr_table.set_link_vr(link)
            if link.cport_from:
                port_local_def = link.cport_from.port.xml(link.vr, name=link.name, causality='local',
                                                          fmi_version=self.fmi_version)
            else:
                # LS-BUS allow Clock generated by fmi-importer
                port = EmbeddedFMUPort("Clock",
                                       {"name": "", "valueReference": -1, "intervalVariability": "triggered"},
                                       fmi_version=3)
                port_local_def = port.xml(link.vr, name=f"container.clock{nb_clocks}", causality='local', fmi_version=self.fmi_version)
                nb_clocks += 1

            if port_local_def:
                print(f"    {port_local_def}", file=xml_file)
                index_offset += 1

        for input_port_name, input_port in self.inputs.items():
            input_port.vr = self.vr_table.add_vr(input_port.type_name)
            # Get Start and XML from first connected input
            start = self.start_values.get(input_port.cport_list[0], None)
            port_input_def = input_port.cport_list[0].port.xml(input_port.vr, name=input_port_name,
                                                               start=start, fmi_version=self.fmi_version)
            if port_input_def:
                print(f"    {port_input_def}", file=xml_file)
                index_offset += 1

        for output_port_name, output_port in self.outputs.items():
            output_port.vr = self.vr_table.add_vr(output_port)
            port_output_def = output_port.port.xml(output_port.vr, name=output_port_name,
                                                   fmi_version=self.fmi_version)
            if port_output_def:
                print(f"    {port_output_def}", file=xml_file)

        if self.fmi_version == 2:
            self.make_fmu_xml_epilog_2(xml_file, index_offset)
        elif self.fmi_version == 3:
            self.make_fmu_xml_epilog_3(xml_file)

    def make_fmu_xml_epilog_2(self, xml_file, index_offset):
        xml_file.write("  </ModelVariables>\n"
                       "\n"
                       "  <ModelStructure>\n")


        if self.outputs:
            xml_file.write("    <Outputs>\n")
            index = index_offset
            for output in self.outputs.values():
                if output.port.type_name in EmbeddedFMUPort.CONTAINER_TO_FMI[2]:
                    print(f'      <Unknown index="{index}"/>', file=xml_file)
                    index += 1
            xml_file.write("    </Outputs>\n"
                           "    <InitialUnknowns>\n")
            index = index_offset
            for output in self.outputs.values():
                if output.port.type_name in EmbeddedFMUPort.CONTAINER_TO_FMI[2]:
                    print(f'      <Unknown index="{index}"/>', file=xml_file)
                    index += 1
            xml_file.write("    </InitialUnknowns>\n")

        xml_file.write("  </ModelStructure>\n"
                       "\n"
                       "</fmiModelDescription>")

    def make_fmu_xml_epilog_3(self, xml_file):
        xml_file.write("  </ModelVariables>\n"
                       "\n"
                       "  <ModelStructure>\n")
        for output in self.outputs.values():
            if output.port.type_name in EmbeddedFMUPort.CONTAINER_TO_FMI[3]:
                print(f'      <Output valueReference="{output.vr}"/>', file=xml_file)
        for output in self.outputs.values():
            if output.port.type_name in EmbeddedFMUPort.CONTAINER_TO_FMI[3]:
                print(f'      <InitialUnknown valueReference="{output.vr}"/>', file=xml_file)
        xml_file.write("  </ModelStructure>\n"
                       "\n"
                       "</fmiModelDescription>")

    def make_fmu_txt(self, txt_file, step_size: float, mt: bool, profiling: bool, sequential: bool):
        print("# Version 5", file=txt_file)
        print("# Container flags <MT> <Profiling> <Sequential>", file=txt_file)
        flags = [ str(int(flag == True)) for flag in (mt, profiling, sequential)]
        print(" ".join(flags), file=txt_file)

        print(f"# Internal time step in seconds", file=txt_file)
        print(f"{step_size}", file=txt_file)
        print(f"# NB of embedded FMU's", file=txt_file)
        print(f"{len(self.involved_fmu)}", file=txt_file)
        fmu_rank: Dict[str, int] = {}
        for i, fmu in enumerate(self.involved_fmu.values()):
            print(f"{fmu.name} {fmu.fmi_version} {int(fmu.has_event_mode)}", file=txt_file)
            print(f"{fmu.model_identifier}", file=txt_file)
            print(f"{fmu.guid}", file=txt_file)
            fmu_rank[fmu.name] = i

        # Prepare data structure
        inputs_per_type: Dict[str, List[ContainerInput]] = defaultdict(list) # Container's INPUT
        outputs_per_type: Dict[str, List[ContainerPort]] = defaultdict(list) # Container's OUTPUT

        fmu_io_list = FMUIOList(self.vr_table)
        clock_list = ClockList(self.involved_fmu)

        local_per_type: Dict[str, List[LocalVariable]] = defaultdict(list)
        links_per_fmu: Dict[str, List[Link]] = defaultdict(list)

        # Fill data structure
        # Inputs
        for input_port_name, input_port in self.inputs.items():
            inputs_per_type[input_port.type_name].append(input_port)

        # Start values
        for input_port, value in self.start_values.items():
            fmu_io_list.add_start_value(input_port, value)

        # Outputs
        for output_port_name, output_port in self.outputs.items():
            outputs_per_type[output_port.port.type_name].append(output_port)

        # Links
        for link in self.links.values():
            # FMU Outputs
            if link.cport_from:
                local_per_type[link.cport_from.port.type_name].append(LocalVariable(link.vr, link.size))
                fmu_io_list.add_output(link.cport_from, link.vr)
            else:
                local_per_type["clock"].append(LocalVariable(link.vr, link.size))
                for cport_to in link.cport_to_list:
                    if cport_to.port.interval_variability == "countdown":
                        logger.info(f"LS-BUS: importer scheduling for '{cport_to.fmu.name}' '{cport_to.port.name}' (clock={cport_to.port.vr}, vr={link.vr})")
                        clock_list.append(cport_to, link.vr)
                        break

            # FMU Inputs
            for cport_to in link.cport_to_list:
                if link.cport_from is not None or not cport_to.fmu.ls.is_bus:
                    # LS-BUS allows, importer to feed clock signal. In this case, cport_from is None
                    # FMU will be fed directly by importer, no need to add input link!
                    if link.cport_from is None or cport_to.port.type_name == link.cport_from.port.type_name:
                        local_vr = link.vr
                    else:
                        local_per_type[cport_to.port.type_name].append(LocalVariable(link.vr_converted[cport_to.port.type_name], link.size))
                        links_per_fmu[link.cport_from.fmu.name].append(link)
                        local_vr = link.vr_converted[cport_to.port.type_name]

                    fmu_io_list.add_input(cport_to, local_vr)

        print(f"# NB local variables:", ", ".join(EmbeddedFMUPort.ALL_TYPES), file=txt_file)
        nb_storage = [f"{self.vr_table.nb_storage(type_name)}" for type_name in EmbeddedFMUPort.ALL_TYPES]
        print(" ".join(nb_storage), file=txt_file, end='')
        print("", file=txt_file)

        print("# CONTAINER I/O: <VR> <DIM> <NB> <FMU_INDEX> <FMU_VR> [<FMU_INDEX> <FMU_VR>]", file=txt_file)
        for type_name in EmbeddedFMUPort.ALL_TYPES:
            print(f"# {type_name}" , file=txt_file)
            nb_local = (len(inputs_per_type[type_name]) +
                        len(outputs_per_type[type_name]) +
                        self.vr_table.nb_local(type_name))
            nb_input_link = 0
            for input_port in inputs_per_type[type_name]:
                nb_input_link += len(input_port.cport_list) - 1
            print(f"{nb_local} {nb_local + nb_input_link}", file=txt_file)
            if type_name == "real64":
                print(f"0 1 1 -1 0", file=txt_file)  # Time slot
                if profiling:
                    for profiling_port, _ in enumerate(self.involved_fmu.values()):
                        print(f"{profiling_port + 1} 1 1 -2 {profiling_port + 1}", file=txt_file)
            elif type_name == "integer32":
                print(f"0 1 1 -1 0", file=txt_file)  # TS Multiplier

            for input_port in inputs_per_type[type_name]:
                cport_string = [f"{fmu_rank[cport.fmu.name]} {cport.port.vr}" for cport in input_port.cport_list]
                print(f"{input_port.vr} {input_port.size} {len(input_port.cport_list)}", " ".join(cport_string), file=txt_file)
            for output_port in outputs_per_type[type_name]:
                print(f"{output_port.vr} {output_port.port.size()} 1 {fmu_rank[output_port.fmu.name]} "
                      f"{output_port.port.vr}", file=txt_file)
            offset_storage = 0
            for local_variable in local_per_type[type_name]:
                print(f"{local_variable.vr} {local_variable.dimension} 1 -1 "
                      f"{(local_variable.vr & 0xFFFFFF) + offset_storage}", file=txt_file)
                offset_storage += local_variable.dimension - 1
        # LINKS
        for fmu in self.involved_fmu.values():
            fmu_io_list.write_txt(fmu.name, txt_file)

            print(f"# Conversion table of {fmu.name}: <VR_FROM> <VR_TO> <CONVERSION>", file=txt_file)
            try:
                nb = 0
                for link in links_per_fmu[fmu.name]:
                    nb += len(link.vr_converted)
                print(f"{nb}", file=txt_file)
                for link in links_per_fmu[fmu.name]:
                    for cport_to in link.cport_to_list:
                        conversion =  link.get_conversion(cport_to)
                        if conversion:
                            print(f"{link.vr} {link.vr_converted[cport_to.port.type_name]} {conversion}",
                                  file=txt_file)
            except KeyError:
                print("0", file=txt_file)

        # CLOCKS
        clock_list.write_txt(txt_file)

    def make_datalog(self, datalog_file):
        print(f"# Datalog filename", file=datalog_file)
        print(f"{self.identifier}-datalog.csv", file=datalog_file)

        ports = defaultdict(list)
        for input_port_name, input_port in self.inputs.items():
            ports[input_port.type_name].append(Port(input_port.vr, input_port_name))
        for output_port_name, output_port in self.outputs.items():
            ports[output_port.port.type_name].append(Port(output_port.vr, output_port_name))
        for link in self.links.values():
            if link.cport_from is None:
                # LS-BUS allows connected input clocks.
                ports[link.cport_to_list[0].port.type_name].append(Port(link.vr, link.name))
            else:
                ports[link.cport_from.port.type_name].append(Port(link.vr, link.name))

        for type_name in EmbeddedFMUPort.ALL_TYPES:
            print(f"# {type_name}: <VR> <NAME>" , file=datalog_file)
            print(f"{len(ports[type_name])}", file=datalog_file)
            for port in ports[type_name]:
                print(f"{port.vr} {port.name}", file=datalog_file)

    @staticmethod
    def long_path(path: Union[str, Path]) -> str:
        # https://stackoverflow.com/questions/14075465/copy-a-file-with-a-too-long-path-to-another-directory-in-python
        if os.name == 'nt':
            return "\\\\?\\" + os.path.abspath(str(path))
        else:
            return path

    @staticmethod
    def copyfile(origin, destination):
        logger.debug(f"Copying {origin} in {destination}")
        shutil.copy(origin, destination)

    def get_platforms(self) -> Generator[Platform, Any, None]:
        fmu_iter = iter(self.involved_fmu.values())
        try:
            fmu = next(fmu_iter)
        except StopIteration:
            raise FMUContainerError("No fmu declared in this container.")

        os_list = fmu.platforms
        logger.debug(f"FMU '{fmu.name}' OS support: {', '.join(fmu.platforms)}.")

        for fmu in fmu_iter:
            logger.debug(f"FMU '{fmu.name}' OS support: {', '.join(fmu.platforms)}.")
            os_list &= fmu.platforms

        suffixes = {
            "Windows": "dll",
            "Linux": "so",
            "Darwin": "dylib"
        }

        origin_bindirs = {
            "Windows": "win64",
            "Linux": "linux64",
            "Darwin": "darwin64"
        }

        if self.fmi_version == 3:
            target_bindirs = {
                "Windows": "x86_64-windows",
                "Linux": "x86_64-linux",
                "Darwin": "aarch64-darwin"
            }
        else:
            target_bindirs = origin_bindirs

        if os_list:
            logger.info(f"Container will be built for {', '.join(os_list)}.")
        else:
            logger.critical("No common OS found for embedded FMU. Try to re-run with '-debug'. Container won't be runnable.")

        for os_name in os_list:
            try:
                yield Platform(origin_bindirs[os_name], suffixes[os_name], target_bindirs[os_name])
            except KeyError:
                raise FMUContainerError(f"OS '{os_name}' is not supported.")

    def make_fmu_skeleton(self, base_directory: Path) -> Path:
        logger.debug(f"Initialize directory '{base_directory}'")

        origin = Path(__file__).parent / "resources"
        resources_directory = base_directory / "resources"
        documentation_directory = base_directory / "documentation"
        binaries_directory = base_directory / "binaries"

        base_directory.mkdir(exist_ok=True)
        resources_directory.mkdir(exist_ok=True)
        binaries_directory.mkdir(exist_ok=True)
        documentation_directory.mkdir(exist_ok=True)

        if self.description_pathname:
            self.copyfile(self.description_pathname, documentation_directory)

        self.copyfile(origin / "fmucontainer.png", base_directory / "model.png")

        for platform in self.get_platforms():
            library_filename = origin / platform.origin_bindir / f"container.{platform.suffixe}"
            if library_filename.is_file():
                binary_directory = binaries_directory / platform.target_bindir
                binary_directory.mkdir(exist_ok=True)
                self.copyfile(library_filename, binary_directory / f"{self.identifier}.{platform.suffixe}")
            else:
                logger.critical(f"File {library_filename} not found.")

        for i, fmu in enumerate(self.involved_fmu.values()):
            with zipfile.ZipFile(fmu.fmu.fmu_filename) as zin:
                zin.extractall(self.long_path(resources_directory / f"{i:02x}"))

        return resources_directory

    def make_fmu_package(self, base_directory: Path, fmu_filename: Path):
        logger.debug(f"Zipping directory '{base_directory}' => '{fmu_filename}'")
        zip_directory = self.long_path(str(base_directory.absolute()))
        offset = len(zip_directory) + 1
        with zipfile.ZipFile(self.fmu_directory / fmu_filename, "w", zipfile.ZIP_DEFLATED) as zip_file:
            def add_file(directory: Path):
                for entry in directory.iterdir():
                    if entry.is_dir():
                        add_file(directory / entry)
                    elif entry.is_file():
                        zip_file.write(str(entry), str(entry)[offset:])

            add_file(Path(zip_directory))
        logger.info(f"'{fmu_filename}' is available.")

    def make_fmu_cleanup(self, base_directory: Path):
        logger.debug(f"Delete directory '{base_directory}'")
        shutil.rmtree(self.long_path(base_directory))

add_implicit_rule(auto_input=True, auto_output=True, auto_link=True, auto_parameter=False, auto_local=False)

Automatically wire unconnected ports of embedded FMUs.

Processes all ports in the following order:

  1. auto_link: Connect outputs to inputs with matching names and types.
  2. auto_output: Expose remaining unconnected outputs.
  3. auto_local: Expose local variables.
  4. auto_input: Expose remaining unconnected inputs.
  5. auto_parameter: Expose parameters.

Parameters:

Name Type Description Default
auto_input bool

Expose unconnected input ports.

True
auto_output bool

Expose unconnected output ports.

True
auto_link bool

Link matching output/input ports automatically.

True
auto_parameter bool

Expose parameter ports.

False
auto_local bool

Expose local variables.

False

Returns:

Name Type Description
AutoWired AutoWired

Record of all automatically created rules.

Source code in fmu_manipulation_toolbox/container.py
def add_implicit_rule(self, auto_input=True, auto_output=True, auto_link=True, auto_parameter=False,
                      auto_local=False) -> AutoWired:
    """Automatically wire unconnected ports of embedded FMUs.

    Processes all ports in the following order:

    1. **auto_link**: Connect outputs to inputs with matching names and types.
    2. **auto_output**: Expose remaining unconnected outputs.
    3. **auto_local**: Expose local variables.
    4. **auto_input**: Expose remaining unconnected inputs.
    5. **auto_parameter**: Expose parameters.

    Args:
        auto_input (bool): Expose unconnected input ports.
        auto_output (bool): Expose unconnected output ports.
        auto_link (bool): Link matching output/input ports automatically.
        auto_parameter (bool): Expose parameter ports.
        auto_local (bool): Expose local variables.

    Returns:
        AutoWired: Record of all automatically created rules.
    """
    auto_wired = AutoWired()
    # Auto Link outputs
    for cport in self.get_all_cports():
        if cport.port.causality == 'output':
            candidates_cport_list = self.find_inputs(cport.port)
            if auto_link and candidates_cport_list:
                for candidate_cport in candidates_cport_list:
                    logger.info(f"AUTO LINK: {cport} -> {candidate_cport}")
                    self.add_link(cport.fmu.name, cport.port.name,
                                  candidate_cport.fmu.name, candidate_cport.port.name)
                    auto_wired.add_link(cport.fmu.name, cport.port.name,
                                        candidate_cport.fmu.name, candidate_cport.port.name)
            elif auto_output and cport not in self.rules:
                logger.info(f"AUTO OUTPUT: Expose {cport}")
                self.add_output(cport.fmu.name, cport.port.name, cport.port.name)
                auto_wired.add_output(cport.fmu.name, cport.port.name, cport.port.name)
        elif cport.port.causality == 'local':
            local_portname = None
            if cport.port.name.startswith("container."):
                local_portname = "container." + cport.fmu.id + "." + cport.port.name[10:]
                logger.info(f"PROFILING: Expose {cport}")
            elif auto_local:
                local_portname = cport.fmu.id + "." + cport.port.name
                logger.info(f"AUTO LOCAL: Expose {cport}")
            if local_portname:
                self.add_output(cport.fmu.name, cport.port.name, local_portname)
                auto_wired.add_output(cport.fmu.name, cport.port.name, local_portname)

    # Auto link inputs
    for cport in self.get_all_cports():
        if cport not in self.rules:
            if auto_parameter and cport.port.causality == 'parameter':
                parameter_name = cport.fmu.id + "." + cport.port.name
                logger.info(f"AUTO PARAMETER: {cport} as {parameter_name}")
                self.add_input(parameter_name, cport.fmu.name, cport.port.name)
                auto_wired.add_parameter(parameter_name, cport.fmu.name, cport.port.name)
            elif auto_input and cport.port.causality == 'input' :
                logger.info(f"AUTO INPUT: Expose {cport}")
                self.add_input(cport.port.name, cport.fmu.name, cport.port.name)
                auto_wired.add_input(cport.port.name, cport.fmu.name, cport.port.name)

    logger.info(f"Auto-wiring: {auto_wired}")

    return auto_wired

add_input(container_port_name, to_fmu_filename, to_port_name)

Expose a port of an embedded FMU as a container input.

Multiple embedded FMU ports can be connected to the same container input (fan-out), provided they share the same type and causality.

Parameters:

Name Type Description Default
container_port_name str

Exposed name on the container. If empty, defaults to to_port_name.

required
to_fmu_filename str

Filename of the embedded FMU.

required
to_port_name str

Name of the input port on the embedded FMU.

required

Raises:

Type Description
FMUContainerError

If the port causality is not "input" or "parameter", or if types do not match an existing input with the same name.

Source code in fmu_manipulation_toolbox/container.py
def add_input(self, container_port_name: str, to_fmu_filename: str, to_port_name: str):
    """Expose a port of an embedded FMU as a container input.

    Multiple embedded FMU ports can be connected to the same container
    input (fan-out), provided they share the same type and causality.

    Args:
        container_port_name (str): Exposed name on the container. If empty,
            defaults to `to_port_name`.
        to_fmu_filename (str): Filename of the embedded FMU.
        to_port_name (str): Name of the input port on the embedded FMU.

    Raises:
        FMUContainerError: If the port causality is not `"input"` or
            `"parameter"`, or if types do not match an existing input
            with the same name.
    """
    if not container_port_name:
        container_port_name = to_port_name

    try:
        cport_to = ContainerPort(self.get_fmu(to_fmu_filename), to_port_name)
    except FMUContainerError as e:
        logger.error(f"Cannot add input: {e}")
        return

    if cport_to.port.causality not in ("input", "parameter"):  # check causality
        raise FMUContainerError(f"Tried to use '{cport_to}' as INPUT of the container but FMU causality is "
                                f"'{cport_to.port.causality}'.")

    try:
        input_port = self.inputs[container_port_name]
        input_port.add_cport(cport_to)
    except KeyError:
        self.inputs[container_port_name] = ContainerInput(container_port_name, cport_to)

    logger.debug(f"INPUT: {to_fmu_filename}:{to_port_name}")
    self.mark_ruled(cport_to, 'INPUT')

Connect an output of one embedded FMU to an input of another.

If both port names match FMI Terminal definitions, a terminal-level connection is made (connecting all member ports). Otherwise, a regular port-to-port link is created.

Parameters:

Name Type Description Default
from_fmu_filename str

Filename of the source FMU.

required
from_port_name str

Output port name (or terminal name).

required
to_fmu_filename str

Filename of the destination FMU.

required
to_port_name str

Input port name (or terminal name).

required

Raises:

Type Description
FMUContainerError

If port causalities are invalid or types are incompatible.

Source code in fmu_manipulation_toolbox/container.py
def add_link(self, from_fmu_filename: str, from_port_name: str, to_fmu_filename: str, to_port_name: str):
    """Connect an output of one embedded FMU to an input of another.

    If both port names match FMI Terminal definitions, a terminal-level
    connection is made (connecting all member ports). Otherwise, a regular
    port-to-port link is created.

    Args:
        from_fmu_filename (str): Filename of the source FMU.
        from_port_name (str): Output port name (or terminal name).
        to_fmu_filename (str): Filename of the destination FMU.
        to_port_name (str): Input port name (or terminal name).

    Raises:
        FMUContainerError: If port causalities are invalid or types
            are incompatible.
    """
    fmu_from = self.get_fmu(from_fmu_filename)
    fmu_to = self.get_fmu(to_fmu_filename)

    if from_port_name in fmu_from.terminals and to_port_name in fmu_to.terminals:
        # TERMINAL Connection
        terminal1 = fmu_from.terminals[from_port_name]
        terminal2 = fmu_to.terminals[to_port_name]
        if terminal1 == terminal2:
            logger.debug(f"Plugging terminals: {terminal1} <-> {terminal2}")
            for terminal1_port_name, terminal2_port_name in terminal1.connect(terminal2):
                self.add_link_regular(fmu_from, terminal1_port_name, fmu_to, terminal2_port_name)
        else:
            logger.error(f"Cannot plug incompatible terminals: {terminal1} <-> {terminal2}")
    else:
        # REGULAR port connection
        self.add_link_regular(fmu_from, from_port_name, fmu_to, to_port_name)

add_output(from_fmu_filename, from_port_name, container_port_name)

Expose a port of an embedded FMU as a container output.

Parameters:

Name Type Description Default
from_fmu_filename str

Filename of the embedded FMU.

required
from_port_name str

Name of the output port on the embedded FMU.

required
container_port_name str

Exposed name on the container. If empty, defaults to from_port_name.

required

Raises:

Type Description
FMUContainerError

If the port causality is not "output" or "local", or if the exposed name is already used.

Source code in fmu_manipulation_toolbox/container.py
def add_output(self, from_fmu_filename: str, from_port_name: str, container_port_name: str):
    """Expose a port of an embedded FMU as a container output.

    Args:
        from_fmu_filename (str): Filename of the embedded FMU.
        from_port_name (str): Name of the output port on the embedded FMU.
        container_port_name (str): Exposed name on the container. If empty,
            defaults to `from_port_name`.

    Raises:
        FMUContainerError: If the port causality is not `"output"` or
            `"local"`, or if the exposed name is already used.
    """
    if not container_port_name:  # empty is allowed
        container_port_name = from_port_name

    try:
        cport_from = ContainerPort(self.get_fmu(from_fmu_filename), from_port_name)
    except FMUContainerError as e:
        logger.error(f"Cannot add output: {e}")
        return

    if cport_from.port.causality not in ("output", "local"):  # check causality
        raise FMUContainerError(f"Tried to use '{cport_from}' as OUTPUT of the container but FMU causality is "
                                f"'{cport_from.port.causality}'.")

    if container_port_name in self.outputs:
        raise FMUContainerError(f"Duplicate OUTPUT {container_port_name} already connected to {cport_from}")

    logger.debug(f"OUTPUT: {from_fmu_filename}:{from_port_name}")
    self.mark_ruled(cport_from, 'OUTPUT')
    self.outputs[container_port_name] = cport_from

add_start_value(fmu_filename, port_name, value)

Set a start value for a port of an embedded FMU.

The value is automatically converted to the appropriate type (float, int, bool, or string).

Parameters:

Name Type Description Default
fmu_filename str

Filename of the embedded FMU.

required
port_name str

Name of the port.

required
value str

Start value as a string.

required

Raises:

Type Description
FMUContainerError

If the value cannot be converted to the port's type.

Source code in fmu_manipulation_toolbox/container.py
def add_start_value(self, fmu_filename: str, port_name: str, value: str):
    """Set a start value for a port of an embedded FMU.

    The value is automatically converted to the appropriate type
    (float, int, bool, or string).

    Args:
        fmu_filename (str): Filename of the embedded FMU.
        port_name (str): Name of the port.
        value (str): Start value as a string.

    Raises:
        FMUContainerError: If the value cannot be converted to the
            port's type.
    """

    try:
        cport = ContainerPort(self.get_fmu(fmu_filename), port_name)
    except FMUContainerError as e:
        logger.error(f"Cannot set start value: {e}")
        return

    # Check dimensions
    value_tokens = str(value).split(' ')
    if not len(value_tokens) == cport.port.size():
        raise FMUContainerError(f"Start value missmatch for {cport.port.type_name} which is dimension {cport.port.size()}")

    # Check type
    for token in value_tokens:
        try:
            if cport.port.type_name.startswith('real'):
                float(token)
            elif cport.port.type_name.startswith('integer') or  cport.port.type_name.startswith('uinteger'):
                int(token)
            elif cport.port.type_name.startswith('boolean'):
                if token not in ("true", "false", "0", "1"):
                    raise ValueError(f"Invalid boolean value: '{token}'")
            elif cport.port.type_name == 'string':
                pass
            else:
                logger.error(f"Start value cannot be set on '{cport.port.type_name}'")
                return
        except ValueError:
            raise FMUContainerError(f"Start value is not conforming to {cport.port.type_name} format.")

    # Format is different for string
    if cport.port.type_name == 'string':
        value = "\n" + "\n".join(value_tokens)

    self.start_values[cport] = value

default_step_size()

Compute the default step size from embedded FMUs.

Uses the GCD of the frequencies of FMUs that cannot handle variable step sizes. If all FMUs support variable steps, returns the largest step size.

Returns:

Name Type Description
float float

Computed step size in seconds.

Source code in fmu_manipulation_toolbox/container.py
def default_step_size(self) -> float:
    """Compute the default step size from embedded FMUs.

    Uses the GCD of the frequencies of FMUs that cannot handle variable
    step sizes. If all FMUs support variable steps, returns the largest
    step size.

    Returns:
        float: Computed step size in seconds.
    """
    default_step_size = 0.1
    freq_set = set()
    for fmu in self.involved_fmu.values():
        if fmu.step_size and fmu.capabilities["canHandleVariableCommunicationStepSize"] == "false":
            freq_set.add(int(1.0/fmu.step_size))

    if not freq_set:
        # all involved FMUs can Handle Variable Communication StepSize
        try:
            step_size_max = 0
            for fmu in self.involved_fmu.values():
                if fmu.step_size > step_size_max:
                    step_size_max = fmu.step_size
            return step_size_max
        except TypeError:
            # all involved FMUs do not specify step_size
            logger.warning(f"Defaulting to step_size={default_step_size}")
            step_size = default_step_size
    else:
        common_freq = math.gcd(*freq_set)
        try:
            step_size = 1.0 / float(common_freq)
        except ZeroDivisionError:
            logger.warning(f"Defaulting to step_size={default_step_size}")
            step_size = default_step_size

    return step_size

drop_port(from_fmu_filename, from_port_name)

Explicitly ignore an output port of an embedded FMU.

Prevents the port from being auto-exposed or flagged as unconnected.

Parameters:

Name Type Description Default
from_fmu_filename str

Filename of the embedded FMU.

required
from_port_name str

Name of the output port to drop.

required

Raises:

Type Description
FMUContainerError

If the port causality is not "output".

Source code in fmu_manipulation_toolbox/container.py
def drop_port(self, from_fmu_filename: str, from_port_name: str):
    """Explicitly ignore an output port of an embedded FMU.

    Prevents the port from being auto-exposed or flagged as unconnected.

    Args:
        from_fmu_filename (str): Filename of the embedded FMU.
        from_port_name (str): Name of the output port to drop.

    Raises:
        FMUContainerError: If the port causality is not `"output"`.
    """

    try:
        cport_from = ContainerPort(self.get_fmu(from_fmu_filename), from_port_name)
    except FMUContainerError as e:
        logger.error(f"Cannot drop port: {e}")
        return

    if not cport_from.port.causality == "output":  # check causality
        raise FMUContainerError(f"{cport_from}: trying to DROP {cport_from.port.causality}")

    logger.debug(f"DROP: {from_fmu_filename}:{from_port_name}")
    self.mark_ruled(cport_from, 'DROP')

get_fmu(fmu_filename)

Load an embedded FMU from the FMU directory.

If the FMU has already been loaded, returns the cached instance.

Parameters:

Name Type Description Default
fmu_filename str

Filename of the FMU (e.g. "model.fmu").

required

Returns:

Name Type Description
EmbeddedFMU EmbeddedFMU

The loaded and analysed FMU.

Raises:

Type Description
FMUContainerError

If the FMU cannot be loaded.

Source code in fmu_manipulation_toolbox/container.py
def get_fmu(self, fmu_filename: str) -> EmbeddedFMU:
    """Load an embedded FMU from the FMU directory.

    If the FMU has already been loaded, returns the cached instance.

    Args:
        fmu_filename (str): Filename of the FMU (e.g. `"model.fmu"`).

    Returns:
        EmbeddedFMU: The loaded and analysed FMU.

    Raises:
        FMUContainerError: If the FMU cannot be loaded.
    """
    fmu_name = Path(fmu_filename).name
    if fmu_name in self.involved_fmu:
        return self.involved_fmu[fmu_name]

    try:
        fmu = EmbeddedFMU(self.fmu_directory / fmu_filename)
        if not fmu.fmi_version == self.fmi_version:
            logger.warning(f"Try to embed FMU-{fmu.fmi_version} into container FMI-{self.fmi_version}.")
        self.involved_fmu[fmu.name] = fmu

        logger.info(f"Involved FMU #{len(self.involved_fmu)}: {fmu}")
    except (FMUContainerError, FMUError) as e:
        raise FMUContainerError(f"Cannot load '{fmu_filename}': {e}")

    return fmu

make_fmu(fmu_filename, step_size=None, debug=False, mt=False, profiling=False, sequential=False, ts_multiplier=False, datalog=False)

Build the FMU Container archive.

Generates the modelDescription.xml, the container.txt runtime configuration, and packages everything into a .fmu zip archive.

Parameters:

Name Type Description Default
fmu_filename str | Path

Output filename for the container.

required
step_size float | None

Internal time step in seconds. If None, deduced from the embedded FMUs.

None
debug bool

Keep intermediate build artifacts.

False
mt bool

Enable multithreaded mode.

False
profiling bool

Enable profiling mode.

False
sequential bool

Use sequential scheduling.

False
ts_multiplier bool

Add a TS_MULTIPLIER input port.

False
datalog bool

Generate a datalog configuration.

False
Source code in fmu_manipulation_toolbox/container.py
def make_fmu(self, fmu_filename: Union[str, Path], step_size: Optional[float] = None, debug=False, mt=False,
             profiling=False, sequential=False, ts_multiplier=False, datalog=False):
    """Build the FMU Container archive.

    Generates the `modelDescription.xml`, the `container.txt` runtime
    configuration, and packages everything into a `.fmu` zip archive.

    Args:
        fmu_filename (str | Path): Output filename for the container.
        step_size (float | None): Internal time step in seconds. If `None`,
            deduced from the embedded FMUs.
        debug (bool): Keep intermediate build artifacts.
        mt (bool): Enable multithreaded mode.
        profiling (bool): Enable profiling mode.
        sequential (bool): Use sequential scheduling.
        ts_multiplier (bool): Add a `TS_MULTIPLIER` input port.
        datalog (bool): Generate a datalog configuration.
    """
    if isinstance(fmu_filename, str):
        fmu_filename = Path(fmu_filename)

    if step_size is None:
        logger.info(f"step_size  will be deduced from the embedded FMU's")
        step_size = self.default_step_size()
    self.sanity_check(step_size)

    logger.info(f"Building FMU '{fmu_filename}', step_size={step_size}")

    base_directory = self.fmu_directory / fmu_filename.with_suffix('')
    resources_directory = self.make_fmu_skeleton(base_directory)

    with open(base_directory / "modelDescription.xml", "wt") as xml_file:
        self.make_fmu_xml(xml_file, step_size, profiling, ts_multiplier)
    with open(resources_directory / "container.txt", "wt") as txt_file:
        self.make_fmu_txt(txt_file, step_size, mt, profiling, sequential)

    if datalog:
        with open(resources_directory / "datalog.txt", "wt") as datalog_file:
            self.make_datalog(datalog_file)

    self.make_fmu_package(base_directory, fmu_filename)
    if not debug:
        self.make_fmu_cleanup(base_directory)

sanity_check(step_size)

Validate the container configuration before building.

Warns about step size mismatches and unconnected ports.

Parameters:

Name Type Description Default
step_size float | None

The container's internal step size.

required
Source code in fmu_manipulation_toolbox/container.py
def sanity_check(self, step_size: Optional[float]):
    """Validate the container configuration before building.

    Warns about step size mismatches and unconnected ports.

    Args:
        step_size (float | None): The container's internal step size.
    """
    for fmu in self.involved_fmu.values():
        if fmu.step_size and fmu.capabilities["canHandleVariableCommunicationStepSize"] == "false":
            ts_ratio = step_size / fmu.step_size
            logger.debug(f"container step_size: {step_size} = {fmu.step_size} x {ts_ratio} for {fmu.name}")
            if ts_ratio < 1.0:
                logger.warning(f"Container step_size={step_size}s is lower than FMU '{fmu.name}' "
                               f"step_size={fmu.step_size}s.")
            if ts_ratio != int(ts_ratio):
                logger.warning(f"Container step_size={step_size}s should divisible by FMU '{fmu.name}' "
                               f"step_size={fmu.step_size}s.")
        for port_name in fmu.ports:
            cport = ContainerPort(fmu, port_name)
            if cport not in self.rules:
                if cport.port.causality == 'input':
                    logger.error(f"Input '{cport}' is not connected")
                if cport.port.causality == 'output':
                    logger.warning(f"Output '{cport}' is not connected")

FMUContainerError

Bases: Exception

Exception raised for errors during FMU Container operations.

Attributes:

Name Type Description
reason str

Human-readable description of the error.

Source code in fmu_manipulation_toolbox/container.py
class FMUContainerError(Exception):
    """Exception raised for errors during FMU Container operations.

    Attributes:
        reason (str): Human-readable description of the error.
    """

    def __init__(self, reason: str):
        self.reason = reason

    def __repr__(self):
        return f"{self.reason}"

FMUIOList

Tracks the I/O mapping between the container and its embedded FMUs.

Organizes inputs, outputs, and start values by type and FMU, supporting both regular and clocked variables. Used to generate the container.txt runtime configuration file.

Attributes:

Name Type Description
vr_table ValueReferenceTable

Reference table for VR lookups.

inputs

Nested mapping [type][fmu_name][clock_vr] → list of (fmu_vr, local_vr) tuples.

outputs

Nested mapping [type][fmu_name][clock_vr] → list of (fmu_vr, local_vr) tuples.

start_values

Mapping [type][fmu_name] → list of (fmu_vr, reset, value) tuples.

Source code in fmu_manipulation_toolbox/container.py
class FMUIOList:
    """Tracks the I/O mapping between the container and its embedded FMUs.

    Organizes inputs, outputs, and start values by type and FMU, supporting
    both regular and clocked variables. Used to generate the `container.txt`
    runtime configuration file.

    Attributes:
        vr_table (ValueReferenceTable): Reference table for VR lookups.
        inputs: Nested mapping `[type][fmu_name][clock_vr]` → list of
            `(fmu_vr, local_vr)` tuples.
        outputs: Nested mapping `[type][fmu_name][clock_vr]` → list of
            `(fmu_vr, local_vr)` tuples.
        start_values: Mapping `[type][fmu_name]` → list of
            `(fmu_vr, reset, value)` tuples.
    """

    def __init__(self, vr_table: ValueReferenceTable):
        self.vr_table = vr_table
        self.inputs = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))  # [type][fmu][clock_vr][(fmu_vr, dim, vr])
        self.nb_clocked_inputs = defaultdict(lambda: defaultdict(lambda: 0))
        self.outputs = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))  # [type][fmu][clock_vr][(fmu_vr, dim, vr])
        self.nb_clocked_outputs = defaultdict(lambda: defaultdict(lambda: 0))
        self.start_values = defaultdict(lambda: defaultdict(list)) # [type][fmu][(cport, value)]

    def add_input(self, cport: ContainerPort, local_vr: int):
        """Register an input mapping for an embedded FMU port.

        Args:
            cport (ContainerPort): The embedded FMU input port.
            local_vr (int): The local value reference in the container.
        """
        if cport.port.clock is None:
            clock = None
        else:
            try:
                clock = self.vr_table.get_local_clock(cport)
            except KeyError:
                logger.error(f"Cannot expose clocked input: {cport}")
                return
            self.nb_clocked_inputs[cport.port.type_name][cport.fmu.name] += 1

        dim = cport.port.size()
        local_offset = self.vr_table.vr_to_local[local_vr]
        fmu_vr = cport.port.vr

        if dim > 1 and cport.fmu.fmi_version == 2:
            for k in range(dim):
                self.inputs[cport.port.type_name][cport.fmu.name][clock].append(
                    IOReference(local_offset + k, 1, fmu_vr + k))
        else:
            self.inputs[cport.port.type_name][cport.fmu.name][clock].append(
                IOReference(local_offset, dim, fmu_vr))

    def add_output(self, cport: ContainerPort, local_vr: int):
        """Register an output mapping for an embedded FMU port.

        Args:
            cport (ContainerPort): The embedded FMU output port.
            local_vr (int): The local value reference in the container.
        """
        if cport.port.clock is None:
            clock = None
        else:
            try:
                clock = self.vr_table.get_local_clock(cport)
            except KeyError:
                logger.error(f"Cannot expose clocked output: {cport}")
                return
            self.nb_clocked_outputs[cport.port.type_name][cport.fmu.name] += 1

        dim = cport.port.size()
        local_offset = self.vr_table.vr_to_local[local_vr]
        fmu_vr = cport.port.vr

        if dim > 1 and cport.fmu.fmi_version == 2:
            for k in range(dim):
                self.outputs[cport.port.type_name][cport.fmu.name][clock].append(
                    IOReference(local_offset + k, 1, fmu_vr + k))
        else:
            self.outputs[cport.port.type_name][cport.fmu.name][clock].append(
                IOReference(local_offset, dim, fmu_vr))


    def add_start_value(self, cport: ContainerPort, value: str):
        """Register a start value for an embedded FMU port.

        Args:
            cport (ContainerPort): The embedded FMU port.
            value (str): The start value.
        """
        reset = 1 if cport.port.causality == "input" else 0
        if cport.port.type_name.startswith("boolean"):
            if value == "true" or value == "1":
                value = "1"
            else:
                value = "0"

        fmu_vr = cport.port.vr
        dim = cport.port.size()
        if dim > 1 and cport.fmu.fmi_version == 2:
            tokens = str(value).split(' ')
            if len(tokens) == 1:
                tokens = tokens * dim
            for k, token in zip(range(dim), tokens):
                self.start_values[cport.port.type_name][cport.fmu.name].append(
                    (fmu_vr + k, 1, reset, token))
        else:
            self.start_values[cport.port.type_name][cport.fmu.name].append(
                (fmu_vr, cport.port.size(), reset, value))

    def write_txt(self, fmu_name: str, txt_file: IO) -> None:
        """Write the I/O mapping for one FMU to the `container.txt` file.

        Args:
            fmu_name (str): Name of the embedded FMU.
            txt_file (IO): Writable text file handle.
        """
        for type_name in EmbeddedFMUPort.ALL_TYPES:
            print(f"# Inputs of {fmu_name} - {type_name}: <LOCAL_OFFSET> <DIM> <FMU_VR>", file=txt_file)
            print(len(self.inputs[type_name][fmu_name][None]), file=txt_file)
            for io_ref in self.inputs[type_name][fmu_name][None]:
                print(f"{io_ref.local_offset} {io_ref.dim} {io_ref.fmu_vr}", file=txt_file)
            if not type_name == "clock":
                print(f"# Clocked Inputs of {fmu_name} - {type_name}: <FMU_VR_CLOCK> <n> <LOCAL_OFFSET> <DIM> <FMU_VR>", file=txt_file)
                print(f"{len(self.inputs[type_name][fmu_name])-1} {self.nb_clocked_inputs[type_name][fmu_name]}",
                      file=txt_file)
                for clock, translation in self.inputs[type_name][fmu_name].items():
                    if not clock is None:
                        s = " ".join([f"{io_ref.local_offset} {io_ref.dim} {io_ref.fmu_vr}" for io_ref in translation])
                        print(f"{clock} {len(translation)} {s}", file=txt_file)

        for type_name in EmbeddedFMUPort.ALL_TYPES[:-2]:  # No start values for binary or clock
            print(f"# Start values of {fmu_name} - {type_name}: <FMU_VR> <DIM> <RESET> <VALUE>", file=txt_file)
            nb_start_lines = len(self.start_values[type_name][fmu_name])
            nb_start_values = 0
            for vr, dim, reset, value in self.start_values[type_name][fmu_name]:
                nb_start_values += dim
            print(f"{nb_start_lines} {nb_start_values}", file=txt_file)
            for vr, dim, reset, value in self.start_values[type_name][fmu_name]:
                print(f"{vr} {dim} {reset} {value}", file=txt_file)

        for type_name in EmbeddedFMUPort.ALL_TYPES:
            print(f"# Outputs of {fmu_name} - {type_name}: <LOCAL_OFFSET> <DIM> <FMU_VR>", file=txt_file)
            print(len(self.outputs[type_name][fmu_name][None]), file=txt_file)
            for io_ref in self.outputs[type_name][fmu_name][None]:
                print(f"{io_ref.local_offset} {io_ref.dim} {io_ref.fmu_vr}", file=txt_file)
            if not type_name == "clock":
                print(f"# Clocked Outputs of {fmu_name} - {type_name}: <FMU_VR_CLOCK> <n> <LOCAL_OFFSET> <DIM> <FMU_VR>", file=txt_file)
                print(f"{len(self.outputs[type_name][fmu_name])-1} {self.nb_clocked_outputs[type_name][fmu_name]}",
                      file=txt_file)
                for clock, translation in self.outputs[type_name][fmu_name].items():
                    if clock is not None:
                        s = " ".join([f"{io_ref.local_offset} {io_ref.dim} {io_ref.fmu_vr}" for io_ref in translation])
                        print(f"{clock} {len(translation)} {s}", file=txt_file)

add_input(cport, local_vr)

Register an input mapping for an embedded FMU port.

Parameters:

Name Type Description Default
cport ContainerPort

The embedded FMU input port.

required
local_vr int

The local value reference in the container.

required
Source code in fmu_manipulation_toolbox/container.py
def add_input(self, cport: ContainerPort, local_vr: int):
    """Register an input mapping for an embedded FMU port.

    Args:
        cport (ContainerPort): The embedded FMU input port.
        local_vr (int): The local value reference in the container.
    """
    if cport.port.clock is None:
        clock = None
    else:
        try:
            clock = self.vr_table.get_local_clock(cport)
        except KeyError:
            logger.error(f"Cannot expose clocked input: {cport}")
            return
        self.nb_clocked_inputs[cport.port.type_name][cport.fmu.name] += 1

    dim = cport.port.size()
    local_offset = self.vr_table.vr_to_local[local_vr]
    fmu_vr = cport.port.vr

    if dim > 1 and cport.fmu.fmi_version == 2:
        for k in range(dim):
            self.inputs[cport.port.type_name][cport.fmu.name][clock].append(
                IOReference(local_offset + k, 1, fmu_vr + k))
    else:
        self.inputs[cport.port.type_name][cport.fmu.name][clock].append(
            IOReference(local_offset, dim, fmu_vr))

add_output(cport, local_vr)

Register an output mapping for an embedded FMU port.

Parameters:

Name Type Description Default
cport ContainerPort

The embedded FMU output port.

required
local_vr int

The local value reference in the container.

required
Source code in fmu_manipulation_toolbox/container.py
def add_output(self, cport: ContainerPort, local_vr: int):
    """Register an output mapping for an embedded FMU port.

    Args:
        cport (ContainerPort): The embedded FMU output port.
        local_vr (int): The local value reference in the container.
    """
    if cport.port.clock is None:
        clock = None
    else:
        try:
            clock = self.vr_table.get_local_clock(cport)
        except KeyError:
            logger.error(f"Cannot expose clocked output: {cport}")
            return
        self.nb_clocked_outputs[cport.port.type_name][cport.fmu.name] += 1

    dim = cport.port.size()
    local_offset = self.vr_table.vr_to_local[local_vr]
    fmu_vr = cport.port.vr

    if dim > 1 and cport.fmu.fmi_version == 2:
        for k in range(dim):
            self.outputs[cport.port.type_name][cport.fmu.name][clock].append(
                IOReference(local_offset + k, 1, fmu_vr + k))
    else:
        self.outputs[cport.port.type_name][cport.fmu.name][clock].append(
            IOReference(local_offset, dim, fmu_vr))

add_start_value(cport, value)

Register a start value for an embedded FMU port.

Parameters:

Name Type Description Default
cport ContainerPort

The embedded FMU port.

required
value str

The start value.

required
Source code in fmu_manipulation_toolbox/container.py
def add_start_value(self, cport: ContainerPort, value: str):
    """Register a start value for an embedded FMU port.

    Args:
        cport (ContainerPort): The embedded FMU port.
        value (str): The start value.
    """
    reset = 1 if cport.port.causality == "input" else 0
    if cport.port.type_name.startswith("boolean"):
        if value == "true" or value == "1":
            value = "1"
        else:
            value = "0"

    fmu_vr = cport.port.vr
    dim = cport.port.size()
    if dim > 1 and cport.fmu.fmi_version == 2:
        tokens = str(value).split(' ')
        if len(tokens) == 1:
            tokens = tokens * dim
        for k, token in zip(range(dim), tokens):
            self.start_values[cport.port.type_name][cport.fmu.name].append(
                (fmu_vr + k, 1, reset, token))
    else:
        self.start_values[cport.port.type_name][cport.fmu.name].append(
            (fmu_vr, cport.port.size(), reset, value))

write_txt(fmu_name, txt_file)

Write the I/O mapping for one FMU to the container.txt file.

Parameters:

Name Type Description Default
fmu_name str

Name of the embedded FMU.

required
txt_file IO

Writable text file handle.

required
Source code in fmu_manipulation_toolbox/container.py
def write_txt(self, fmu_name: str, txt_file: IO) -> None:
    """Write the I/O mapping for one FMU to the `container.txt` file.

    Args:
        fmu_name (str): Name of the embedded FMU.
        txt_file (IO): Writable text file handle.
    """
    for type_name in EmbeddedFMUPort.ALL_TYPES:
        print(f"# Inputs of {fmu_name} - {type_name}: <LOCAL_OFFSET> <DIM> <FMU_VR>", file=txt_file)
        print(len(self.inputs[type_name][fmu_name][None]), file=txt_file)
        for io_ref in self.inputs[type_name][fmu_name][None]:
            print(f"{io_ref.local_offset} {io_ref.dim} {io_ref.fmu_vr}", file=txt_file)
        if not type_name == "clock":
            print(f"# Clocked Inputs of {fmu_name} - {type_name}: <FMU_VR_CLOCK> <n> <LOCAL_OFFSET> <DIM> <FMU_VR>", file=txt_file)
            print(f"{len(self.inputs[type_name][fmu_name])-1} {self.nb_clocked_inputs[type_name][fmu_name]}",
                  file=txt_file)
            for clock, translation in self.inputs[type_name][fmu_name].items():
                if not clock is None:
                    s = " ".join([f"{io_ref.local_offset} {io_ref.dim} {io_ref.fmu_vr}" for io_ref in translation])
                    print(f"{clock} {len(translation)} {s}", file=txt_file)

    for type_name in EmbeddedFMUPort.ALL_TYPES[:-2]:  # No start values for binary or clock
        print(f"# Start values of {fmu_name} - {type_name}: <FMU_VR> <DIM> <RESET> <VALUE>", file=txt_file)
        nb_start_lines = len(self.start_values[type_name][fmu_name])
        nb_start_values = 0
        for vr, dim, reset, value in self.start_values[type_name][fmu_name]:
            nb_start_values += dim
        print(f"{nb_start_lines} {nb_start_values}", file=txt_file)
        for vr, dim, reset, value in self.start_values[type_name][fmu_name]:
            print(f"{vr} {dim} {reset} {value}", file=txt_file)

    for type_name in EmbeddedFMUPort.ALL_TYPES:
        print(f"# Outputs of {fmu_name} - {type_name}: <LOCAL_OFFSET> <DIM> <FMU_VR>", file=txt_file)
        print(len(self.outputs[type_name][fmu_name][None]), file=txt_file)
        for io_ref in self.outputs[type_name][fmu_name][None]:
            print(f"{io_ref.local_offset} {io_ref.dim} {io_ref.fmu_vr}", file=txt_file)
        if not type_name == "clock":
            print(f"# Clocked Outputs of {fmu_name} - {type_name}: <FMU_VR_CLOCK> <n> <LOCAL_OFFSET> <DIM> <FMU_VR>", file=txt_file)
            print(f"{len(self.outputs[type_name][fmu_name])-1} {self.nb_clocked_outputs[type_name][fmu_name]}",
                  file=txt_file)
            for clock, translation in self.outputs[type_name][fmu_name].items():
                if clock is not None:
                    s = " ".join([f"{io_ref.local_offset} {io_ref.dim} {io_ref.fmu_vr}" for io_ref in translation])
                    print(f"{clock} {len(translation)} {s}", file=txt_file)

Represents an internal connection between embedded FMUs inside a container.

A link routes one output port to one or more input ports. When the source and target types differ, automatic type conversion is applied if a conversion function exists.

Attributes:

Name Type Description
CONVERSION_FUNCTION dict[str, str]

Mapping from type pair strings (e.g. "real32/real64") to conversion function identifiers.

name str

Human-readable name derived from the source FMU and port.

cport_from ContainerPort | None

Source output port, or None for importer-generated clocks.

cport_to_list list[ContainerPort]

Destination input ports.

vr int | None

Value reference for the local variable holding the link value.

vr_converted dict[str, int | None]

Value references for type-converted copies, keyed by target type name.

Source code in fmu_manipulation_toolbox/container.py
class Link:
    """Represents an internal connection between embedded FMUs inside a container.

    A link routes one output port to one or more input ports. When the source
    and target types differ, automatic type conversion is applied if a
    conversion function exists.

    Attributes:
        CONVERSION_FUNCTION (dict[str, str]): Mapping from type pair strings
            (e.g. `"real32/real64"`) to conversion function identifiers.
        name (str): Human-readable name derived from the source FMU and port.
        cport_from (ContainerPort | None): Source output port, or `None` for
            importer-generated clocks.
        cport_to_list (list[ContainerPort]): Destination input ports.
        vr (int | None): Value reference for the local variable holding the link value.
        vr_converted (dict[str, int | None]): Value references for type-converted
            copies, keyed by target type name.
    """

    CONVERSION_FUNCTION = {
        # ------------------------------------------------------------------
        # Lossless conversions (widening integers, F32 -> F64, boolean/int
        # normalisation, boolean -> numeric which yields 0 or 1).
        # ------------------------------------------------------------------
        "real32/real64": "F32_F64",

        "integer8/integer16": "D8_D16",
        "integer8/uinteger16": "D8_U16",
        "integer8/integer32": "D8_D32",
        "integer8/uinteger32": "D8_U32",
        "integer8/integer64": "D8_D64",
        "integer8/uinteger64": "D8_U64",

        "uinteger8/integer16": "U8_D16",
        "uinteger8/uinteger16": "U8_U16",
        "uinteger8/integer32": "U8_D32",
        "uinteger8/uinteger32": "U8_U32",
        "uinteger8/integer64": "U8_D64",
        "uinteger8/uinteger64": "U8_U64",

        "integer16/integer32": "D16_D32",
        "integer16/uinteger32": "D16_U32",
        "integer16/integer64": "D16_D64",
        "integer16/uinteger64": "D16_U64",

        "uinteger16/integer32": "U16_D32",
        "uinteger16/uinteger32": "U16_U32",
        "uinteger16/integer64": "U16_D64",
        "uinteger16/uinteger64": "U16_U64",

        "integer32/integer64": "D32_D64",
        "integer32/uinteger64": "D32_U64",

        "uinteger32/integer64": "U32_D64",
        "uinteger32/uinteger64": "U32_U64",

        "boolean/boolean1": "B_B1",
        "boolean1/boolean": "B1_B",

        # Boolean -> numeric: result is 0 or 1, lossless.
        "boolean/real32":    "B_F32",
        "boolean/real64":    "B_F64",
        "boolean/integer8":  "B_D8",
        "boolean/uinteger8": "B_U8",
        "boolean/integer16": "B_D16",
        "boolean/uinteger16":"B_U16",
        "boolean/integer32": "B_D32",
        "boolean/uinteger32":"B_U32",
        "boolean/integer64": "B_D64",
        "boolean/uinteger64":"B_U64",

        "boolean1/real32":    "B1_F32",
        "boolean1/real64":    "B1_F64",
        "boolean1/integer8":  "B1_D8",
        "boolean1/uinteger8": "B1_U8",
        "boolean1/integer16": "B1_D16",
        "boolean1/uinteger16":"B1_U16",
        "boolean1/integer32": "B1_D32",
        "boolean1/uinteger32":"B1_U32",
        "boolean1/integer64": "B1_D64",
        "boolean1/uinteger64":"B1_U64",

        # ------------------------------------------------------------------
        # Lossy conversions (prefixed with '_' so the C side and the Python
        # side both flag them). A warning is emitted when such a conversion
        # is instantiated (see Link.add_target).
        # ------------------------------------------------------------------

        # From F32
        "real32/integer8":   "_F32_D8",
        "real32/uinteger8":  "_F32_U8",
        "real32/integer16":  "_F32_D16",
        "real32/uinteger16": "_F32_U16",
        "real32/integer32":  "_F32_D32",
        "real32/uinteger32": "_F32_U32",
        "real32/integer64":  "_F32_D64",
        "real32/uinteger64": "_F32_U64",

        # From F64
        "real64/real32":     "_F64_F32",
        "real64/integer8":   "_F64_D8",
        "real64/uinteger8":  "_F64_U8",
        "real64/integer16":  "_F64_D16",
        "real64/uinteger16": "_F64_U16",
        "real64/integer32":  "_F64_D32",
        "real64/uinteger32": "_F64_U32",
        "real64/integer64":  "_F64_D64",
        "real64/uinteger64": "_F64_U64",

        # From D8 / U8
        "integer8/real32":   "_D8_F32",
        "integer8/real64":   "_D8_F64",
        "integer8/uinteger8": "_D8_U8",

        "uinteger8/real32":  "_U8_F32",
        "uinteger8/real64":  "_U8_F64",
        "uinteger8/integer8": "_U8_D8",

        # From D16 / U16
        "integer16/real32":   "_D16_F32",
        "integer16/real64":   "_D16_F64",
        "integer16/integer8": "_D16_D8",
        "integer16/uinteger8":"_D16_U8",
        "integer16/uinteger16":"_D16_U16",

        "uinteger16/real32":   "_U16_F32",
        "uinteger16/real64":   "_U16_F64",
        "uinteger16/integer8": "_U16_D8",
        "uinteger16/uinteger8":"_U16_U8",
        "uinteger16/integer16":"_U16_D16",

        # From D32 / U32
        "integer32/real32":    "_D32_F32",
        "integer32/real64":    "_D32_F64",
        "integer32/integer8":  "_D32_D8",
        "integer32/uinteger8": "_D32_U8",
        "integer32/integer16": "_D32_D16",
        "integer32/uinteger16":"_D32_U16",
        "integer32/uinteger32":"_D32_U32",

        "uinteger32/real32":    "_U32_F32",
        "uinteger32/real64":    "_U32_F64",
        "uinteger32/integer8":  "_U32_D8",
        "uinteger32/uinteger8": "_U32_U8",
        "uinteger32/integer16": "_U32_D16",
        "uinteger32/uinteger16":"_U32_U16",
        "uinteger32/integer32": "_U32_D32",

        # From D64 / U64
        "integer64/real32":    "_D64_F32",
        "integer64/real64":    "_D64_F64",
        "integer64/integer8":  "_D64_D8",
        "integer64/uinteger8": "_D64_U8",
        "integer64/integer16": "_D64_D16",
        "integer64/uinteger16":"_D64_U16",
        "integer64/integer32": "_D64_D32",
        "integer64/uinteger32":"_D64_U32",
        "integer64/uinteger64":"_D64_U64",

        "uinteger64/real32":    "_U64_F32",
        "uinteger64/real64":    "_U64_F64",
        "uinteger64/integer8":  "_U64_D8",
        "uinteger64/uinteger8": "_U64_U8",
        "uinteger64/integer16": "_U64_D16",
        "uinteger64/uinteger16":"_U64_U16",
        "uinteger64/integer32": "_U64_D32",
        "uinteger64/uinteger32":"_U64_U32",
        "uinteger64/integer64": "_U64_D64",

        # Numeric -> boolean: non-zero is considered true.
        "real32/boolean":     "_F32_B",
        "real64/boolean":     "_F64_B",
        "integer8/boolean":   "_D8_B",
        "uinteger8/boolean":  "_U8_B",
        "integer16/boolean":  "_D16_B",
        "uinteger16/boolean": "_U16_B",
        "integer32/boolean":  "_D32_B",
        "uinteger32/boolean": "_U32_B",
        "integer64/boolean":  "_D64_B",
        "uinteger64/boolean": "_U64_B",

        "real32/boolean1":     "_F32_B1",
        "real64/boolean1":     "_F64_B1",
        "integer8/boolean1":   "_D8_B1",
        "uinteger8/boolean1":  "_U8_B1",
        "integer16/boolean1":  "_D16_B1",
        "uinteger16/boolean1": "_U16_B1",
        "integer32/boolean1":  "_D32_B1",
        "uinteger32/boolean1": "_U32_B1",
        "integer64/boolean1":  "_D64_B1",
        "uinteger64/boolean1": "_U64_B1",
    }

    def __init__(self, cport_from: ContainerPort):
        self.name = cport_from.fmu.id + "." + cport_from.port.name  # strip .fmu suffix
        self.cport_from = cport_from
        self.cport_to_list: List[ContainerPort] = []
        self.size = cport_from.port.size()
        self.vr: Optional[int] = None
        self.vr_converted: Dict[str, Optional[int]] = {}

        if not cport_from.port.causality == "output":
            if cport_from.port.type_name == "clock":
                # LS-BUS allows connected input clocks.
                self.cport_from = None
                self.add_target(cport_from)
            else:
                raise FMUContainerError(f"{cport_from} is {cport_from.port.causality} instead of OUTPUT")

    def add_target(self, cport_to: ContainerPort):
        """Add a destination input port to this link.

        Args:
            cport_to (ContainerPort): The input port to connect.

        Raises:
            FMUContainerError: If the port is not an input, or if types are
                incompatible and no conversion exists.
        """
        if not cport_to.port.causality == "input":
            raise FMUContainerError(f"{cport_to} is {cport_to.port.causality} instead of INPUT")

        if cport_to.port.type_name == "clock" and self.cport_from is None:
            self.cport_to_list.append(cport_to)
        elif cport_to.port.type_name == self.cport_from.port.type_name:
            if cport_to.port.dimensions == self.cport_from.port.dimensions:
                self.cport_to_list.append(cport_to)
            else:
                raise FMUContainerError(f"failed to connect {self.cport_from} to {cport_to} due dimensions mismatch.")
        else:
            conversion = self.get_conversion(cport_to)
            if conversion:
                if conversion.startswith("_"):
                    logger.warning(f"Lossy conversion {conversion.lstrip('_')} applied "
                                   f"from {self.cport_from} to {cport_to}.")
                self.cport_to_list.append(cport_to)
                self.vr_converted[cport_to.port.type_name] = None
            else:
                raise FMUContainerError(f"failed to connect {self.cport_from} to {cport_to} due to type.")

    def get_conversion(self, cport_to: ContainerPort) -> Optional[str]:
        """Look up the conversion function for connecting to a different type.

        Args:
            cport_to (ContainerPort): The target port with a potentially
                different type.

        Returns:
            str | None: Conversion function identifier, or `None` if no
                conversion is available.
        """
        try:
            conversion = f"{self.cport_from.port.type_name}/{cport_to.port.type_name}"
            return self.CONVERSION_FUNCTION[conversion]
        except KeyError:
            return None

    def nb_local(self) -> int:
        """Return the number of local variables needed for this link.

        Returns:
            int: `1` for the main value plus one per type-converted copy.
        """
        return 1+len(self.vr_converted)

add_target(cport_to)

Add a destination input port to this link.

Parameters:

Name Type Description Default
cport_to ContainerPort

The input port to connect.

required

Raises:

Type Description
FMUContainerError

If the port is not an input, or if types are incompatible and no conversion exists.

Source code in fmu_manipulation_toolbox/container.py
def add_target(self, cport_to: ContainerPort):
    """Add a destination input port to this link.

    Args:
        cport_to (ContainerPort): The input port to connect.

    Raises:
        FMUContainerError: If the port is not an input, or if types are
            incompatible and no conversion exists.
    """
    if not cport_to.port.causality == "input":
        raise FMUContainerError(f"{cport_to} is {cport_to.port.causality} instead of INPUT")

    if cport_to.port.type_name == "clock" and self.cport_from is None:
        self.cport_to_list.append(cport_to)
    elif cport_to.port.type_name == self.cport_from.port.type_name:
        if cport_to.port.dimensions == self.cport_from.port.dimensions:
            self.cport_to_list.append(cport_to)
        else:
            raise FMUContainerError(f"failed to connect {self.cport_from} to {cport_to} due dimensions mismatch.")
    else:
        conversion = self.get_conversion(cport_to)
        if conversion:
            if conversion.startswith("_"):
                logger.warning(f"Lossy conversion {conversion.lstrip('_')} applied "
                               f"from {self.cport_from} to {cport_to}.")
            self.cport_to_list.append(cport_to)
            self.vr_converted[cport_to.port.type_name] = None
        else:
            raise FMUContainerError(f"failed to connect {self.cport_from} to {cport_to} due to type.")

get_conversion(cport_to)

Look up the conversion function for connecting to a different type.

Parameters:

Name Type Description Default
cport_to ContainerPort

The target port with a potentially different type.

required

Returns:

Type Description
Optional[str]

str | None: Conversion function identifier, or None if no conversion is available.

Source code in fmu_manipulation_toolbox/container.py
def get_conversion(self, cport_to: ContainerPort) -> Optional[str]:
    """Look up the conversion function for connecting to a different type.

    Args:
        cport_to (ContainerPort): The target port with a potentially
            different type.

    Returns:
        str | None: Conversion function identifier, or `None` if no
            conversion is available.
    """
    try:
        conversion = f"{self.cport_from.port.type_name}/{cport_to.port.type_name}"
        return self.CONVERSION_FUNCTION[conversion]
    except KeyError:
        return None

nb_local()

Return the number of local variables needed for this link.

Returns:

Name Type Description
int int

1 for the main value plus one per type-converted copy.

Source code in fmu_manipulation_toolbox/container.py
def nb_local(self) -> int:
    """Return the number of local variables needed for this link.

    Returns:
        int: `1` for the main value plus one per type-converted copy.
    """
    return 1+len(self.vr_converted)

ValueReferenceTable

Allocates and tracks value references for the container's local variables.

Value references are encoded with a type mask in the upper bits, allowing the container runtime to identify the type from the VR alone.

Attributes:

Name Type Description
vr_table dict[str, int]

Next available VR index per type.

masks dict[str, int]

Bit mask per type, shifted to the upper byte.

nb_local_variable dict[str, int]

Count of local variables per type.

local_clock dict

Mapping from (EmbeddedFMU, fmu_vr) to local clock VR.

Source code in fmu_manipulation_toolbox/container.py
class ValueReferenceTable:
    """Allocates and tracks value references for the container's local variables.

    Value references are encoded with a type mask in the upper bits,
    allowing the container runtime to identify the type from the VR alone.

    Attributes:
        vr_table (dict[str, int]): Next available VR index per type.
        masks (dict[str, int]): Bit mask per type, shifted to the upper byte.
        nb_local_variable (dict[str, int]): Count of local variables per type.
        local_clock (dict): Mapping from `(EmbeddedFMU, fmu_vr)` to local
            clock VR.
    """

    def __init__(self):
        self.vr_table:Dict[str, int] = {}
        self.masks: Dict[str, int] = {}
        self.nb_local_variable:Dict[str, int] = {}
        self.nb_local_storage: Dict[str, int] = {}
        self.vr_to_local:Dict[int, int] = {}

        self.local_clock = {}
        for i, type_name in enumerate(EmbeddedFMUPort.ALL_TYPES):
            self.vr_table[type_name] = 0
            self.masks[type_name] = i << 24
            self.nb_local_variable[type_name] = 0
            self.nb_local_storage[type_name] = 0

    def add_vr(self, port_or_type_name: Union[ContainerPort, str], local: bool = False, port_size=1) -> int:
        """Allocate a new value reference.

        Args:
            port_or_type_name (ContainerPort | str): A port (type is inferred)
                or a type name string.
            local (bool): Whether this VR is for a local variable.

        Returns:
            int: The allocated value reference with type mask applied.
        """
        if isinstance(port_or_type_name, ContainerPort):
            type_name = port_or_type_name.port.type_name
        else:
            type_name = port_or_type_name

        if isinstance(port_or_type_name, ContainerPort):
            size = port_or_type_name.port.size()
        else:
            size = port_size

        vr = self.vr_table[type_name] | self.masks[type_name]
        self.vr_table[type_name] += 1

        if local:
            self.vr_to_local[vr] = self.nb_local_storage[type_name]
            self.nb_local_variable[type_name] += 1
            self.nb_local_storage[type_name] += size

        return vr

    def set_link_vr(self, link: Link):
        """Allocate value references for a link and its type-converted copies.

        Args:
            link (Link): The link to assign VRs to.
        """
        if link.cport_from is None:
            link.vr = self.add_vr("clock", local=True)
        else:
            link.vr = self.add_vr(link.cport_from, local=True)
            if link.cport_from.port.type_name == "clock":
                self.local_clock[(link.cport_from.fmu, link.cport_from.port.vr)] = link.vr

        for cport_to in link.cport_to_list:
            if cport_to.port.type_name == "clock":
                self.local_clock[(cport_to.fmu, cport_to.port.vr)] = link.vr

        for type_name in link.vr_converted.keys():
            link.vr_converted[type_name] = self.add_vr(type_name, local=True,
                                                       port_size=link.cport_from.port.size())

    def get_local_clock(self, cport: ContainerPort) -> int:
        """Get the local VR for a clock associated with a clocked port.

        Args:
            cport (ContainerPort): The clocked port.

        Returns:
            int: The local value reference of the clock.
        """
        return self.local_clock[(cport.fmu, int(cport.port.clock))]


    def nb_local(self, type_name: str) -> int:
        """Return the number of local variables for a given type.

        Args:
            type_name (str): Container type name (e.g. `"real64"`).

        Returns:
            int: Number of local variables of this type.
        """
        return self.nb_local_variable[type_name]

    def nb_storage(self, type_name: str) -> int:
        return self.nb_local_storage[type_name]

add_vr(port_or_type_name, local=False, port_size=1)

Allocate a new value reference.

Parameters:

Name Type Description Default
port_or_type_name ContainerPort | str

A port (type is inferred) or a type name string.

required
local bool

Whether this VR is for a local variable.

False

Returns:

Name Type Description
int int

The allocated value reference with type mask applied.

Source code in fmu_manipulation_toolbox/container.py
def add_vr(self, port_or_type_name: Union[ContainerPort, str], local: bool = False, port_size=1) -> int:
    """Allocate a new value reference.

    Args:
        port_or_type_name (ContainerPort | str): A port (type is inferred)
            or a type name string.
        local (bool): Whether this VR is for a local variable.

    Returns:
        int: The allocated value reference with type mask applied.
    """
    if isinstance(port_or_type_name, ContainerPort):
        type_name = port_or_type_name.port.type_name
    else:
        type_name = port_or_type_name

    if isinstance(port_or_type_name, ContainerPort):
        size = port_or_type_name.port.size()
    else:
        size = port_size

    vr = self.vr_table[type_name] | self.masks[type_name]
    self.vr_table[type_name] += 1

    if local:
        self.vr_to_local[vr] = self.nb_local_storage[type_name]
        self.nb_local_variable[type_name] += 1
        self.nb_local_storage[type_name] += size

    return vr

get_local_clock(cport)

Get the local VR for a clock associated with a clocked port.

Parameters:

Name Type Description Default
cport ContainerPort

The clocked port.

required

Returns:

Name Type Description
int int

The local value reference of the clock.

Source code in fmu_manipulation_toolbox/container.py
def get_local_clock(self, cport: ContainerPort) -> int:
    """Get the local VR for a clock associated with a clocked port.

    Args:
        cport (ContainerPort): The clocked port.

    Returns:
        int: The local value reference of the clock.
    """
    return self.local_clock[(cport.fmu, int(cport.port.clock))]

nb_local(type_name)

Return the number of local variables for a given type.

Parameters:

Name Type Description Default
type_name str

Container type name (e.g. "real64").

required

Returns:

Name Type Description
int int

Number of local variables of this type.

Source code in fmu_manipulation_toolbox/container.py
def nb_local(self, type_name: str) -> int:
    """Return the number of local variables for a given type.

    Args:
        type_name (str): Container type name (e.g. `"real64"`).

    Returns:
        int: Number of local variables of this type.
    """
    return self.nb_local_variable[type_name]

Allocate value references for a link and its type-converted copies.

Parameters:

Name Type Description Default
link Link

The link to assign VRs to.

required
Source code in fmu_manipulation_toolbox/container.py
def set_link_vr(self, link: Link):
    """Allocate value references for a link and its type-converted copies.

    Args:
        link (Link): The link to assign VRs to.
    """
    if link.cport_from is None:
        link.vr = self.add_vr("clock", local=True)
    else:
        link.vr = self.add_vr(link.cport_from, local=True)
        if link.cport_from.port.type_name == "clock":
            self.local_clock[(link.cport_from.fmu, link.cport_from.port.vr)] = link.vr

    for cport_to in link.cport_to_list:
        if cport_to.port.type_name == "clock":
            self.local_clock[(cport_to.fmu, cport_to.port.vr)] = link.vr

    for type_name in link.vr_converted.keys():
        link.vr_converted[type_name] = self.add_vr(type_name, local=True,
                                                   port_size=link.cport_from.port.size())