fmu-manipulation-toolbox 0.0.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. fmu_manipulation_toolbox/__init__.py +0 -0
  2. fmu_manipulation_toolbox/__main__.py +25 -0
  3. fmu_manipulation_toolbox/_version.py +24 -0
  4. fmu_manipulation_toolbox/assembly.py +1033 -0
  5. fmu_manipulation_toolbox/assistant/__init__.py +37 -0
  6. fmu_manipulation_toolbox/assistant/auth.py +115 -0
  7. fmu_manipulation_toolbox/assistant/bridge.py +112 -0
  8. fmu_manipulation_toolbox/assistant/fmutools.py +274 -0
  9. fmu_manipulation_toolbox/assistant/headless.py +379 -0
  10. fmu_manipulation_toolbox/assistant/knowledge.py +285 -0
  11. fmu_manipulation_toolbox/assistant/models.py +211 -0
  12. fmu_manipulation_toolbox/assistant/paths.py +142 -0
  13. fmu_manipulation_toolbox/assistant/server.py +791 -0
  14. fmu_manipulation_toolbox/checker.py +378 -0
  15. fmu_manipulation_toolbox/cli/__init__.py +0 -0
  16. fmu_manipulation_toolbox/cli/datalog2pcap.py +173 -0
  17. fmu_manipulation_toolbox/cli/fmucontainer.py +115 -0
  18. fmu_manipulation_toolbox/cli/fmusplit.py +50 -0
  19. fmu_manipulation_toolbox/cli/fmutool.py +153 -0
  20. fmu_manipulation_toolbox/cli/fmutool_mcp.py +170 -0
  21. fmu_manipulation_toolbox/cli/utils.py +70 -0
  22. fmu_manipulation_toolbox/container.py +2367 -0
  23. fmu_manipulation_toolbox/gui/__init__.py +0 -0
  24. fmu_manipulation_toolbox/gui/__main__.py +195 -0
  25. fmu_manipulation_toolbox/gui/fmucontainer/__init__.py +2 -0
  26. fmu_manipulation_toolbox/gui/fmucontainer/__main__.py +339 -0
  27. fmu_manipulation_toolbox/gui/fmucontainer/assembly_io.py +631 -0
  28. fmu_manipulation_toolbox/gui/fmucontainer/details/__init__.py +19 -0
  29. fmu_manipulation_toolbox/gui/fmucontainer/details/container_detail.py +112 -0
  30. fmu_manipulation_toolbox/gui/fmucontainer/details/fmu_detail.py +384 -0
  31. fmu_manipulation_toolbox/gui/fmucontainer/details/stack.py +88 -0
  32. fmu_manipulation_toolbox/gui/fmucontainer/details/wire_detail.py +901 -0
  33. fmu_manipulation_toolbox/gui/fmucontainer/graph/__init__.py +41 -0
  34. fmu_manipulation_toolbox/gui/fmucontainer/graph/constants.py +42 -0
  35. fmu_manipulation_toolbox/gui/fmucontainer/graph/node.py +643 -0
  36. fmu_manipulation_toolbox/gui/fmucontainer/graph/node_info_dialog.py +93 -0
  37. fmu_manipulation_toolbox/gui/fmucontainer/graph/scene.py +225 -0
  38. fmu_manipulation_toolbox/gui/fmucontainer/graph/view.py +295 -0
  39. fmu_manipulation_toolbox/gui/fmucontainer/graph/widget.py +49 -0
  40. fmu_manipulation_toolbox/gui/fmucontainer/graph/wire.py +499 -0
  41. fmu_manipulation_toolbox/gui/fmucontainer/mcp_bridge.py +693 -0
  42. fmu_manipulation_toolbox/gui/fmucontainer/tree/__init__.py +19 -0
  43. fmu_manipulation_toolbox/gui/fmucontainer/tree/model.py +78 -0
  44. fmu_manipulation_toolbox/gui/fmucontainer/tree/panel.py +207 -0
  45. fmu_manipulation_toolbox/gui/fmucontainer/tree/sync.py +44 -0
  46. fmu_manipulation_toolbox/gui/fmucontainer/tree/widget.py +515 -0
  47. fmu_manipulation_toolbox/gui/fmueditor/__init__.py +0 -0
  48. fmu_manipulation_toolbox/gui/fmueditor/__main__.py +625 -0
  49. fmu_manipulation_toolbox/gui/fmutool/__init__.py +0 -0
  50. fmu_manipulation_toolbox/gui/fmutool/__main__.py +313 -0
  51. fmu_manipulation_toolbox/gui/helper.py +581 -0
  52. fmu_manipulation_toolbox/gui/style.py +311 -0
  53. fmu_manipulation_toolbox/help.py +109 -0
  54. fmu_manipulation_toolbox/ls.py +35 -0
  55. fmu_manipulation_toolbox/model_description.py +488 -0
  56. fmu_manipulation_toolbox/operations.py +1060 -0
  57. fmu_manipulation_toolbox/remoting.py +168 -0
  58. fmu_manipulation_toolbox/resources/checkbox-checked-disabled.png +0 -0
  59. fmu_manipulation_toolbox/resources/checkbox-checked-hover.png +0 -0
  60. fmu_manipulation_toolbox/resources/checkbox-checked.png +0 -0
  61. fmu_manipulation_toolbox/resources/checkbox-unchecked-disabled.png +0 -0
  62. fmu_manipulation_toolbox/resources/checkbox-unchecked-hover.png +0 -0
  63. fmu_manipulation_toolbox/resources/checkbox-unchecked.png +0 -0
  64. fmu_manipulation_toolbox/resources/container.png +0 -0
  65. fmu_manipulation_toolbox/resources/darwin64/container.dylib +0 -0
  66. fmu_manipulation_toolbox/resources/drop_fmu.png +0 -0
  67. fmu_manipulation_toolbox/resources/fmi-2.0/fmi2Annotation.xsd +58 -0
  68. fmu_manipulation_toolbox/resources/fmi-2.0/fmi2AttributeGroups.xsd +78 -0
  69. fmu_manipulation_toolbox/resources/fmi-2.0/fmi2ModelDescription.xsd +345 -0
  70. fmu_manipulation_toolbox/resources/fmi-2.0/fmi2ScalarVariable.xsd +218 -0
  71. fmu_manipulation_toolbox/resources/fmi-2.0/fmi2Type.xsd +89 -0
  72. fmu_manipulation_toolbox/resources/fmi-2.0/fmi2Unit.xsd +116 -0
  73. fmu_manipulation_toolbox/resources/fmi-2.0/fmi2VariableDependency.xsd +92 -0
  74. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3Annotation.xsd +51 -0
  75. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3AttributeGroups.xsd +119 -0
  76. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3BuildDescription.xsd +117 -0
  77. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3InterfaceType.xsd +80 -0
  78. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3LayeredStandardManifest.xsd +93 -0
  79. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3ModelDescription.xsd +131 -0
  80. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3Terminal.xsd +87 -0
  81. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3TerminalsAndIcons.xsd +84 -0
  82. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3Type.xsd +207 -0
  83. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3Unit.xsd +69 -0
  84. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3Variable.xsd +413 -0
  85. fmu_manipulation_toolbox/resources/fmi-3.0/fmi3VariableDependency.xsd +64 -0
  86. fmu_manipulation_toolbox/resources/fmu.png +0 -0
  87. fmu_manipulation_toolbox/resources/fmu_manipulation_toolbox.png +0 -0
  88. fmu_manipulation_toolbox/resources/fmucontainer.png +0 -0
  89. fmu_manipulation_toolbox/resources/fmueditor.png +0 -0
  90. fmu_manipulation_toolbox/resources/fmutool.png +0 -0
  91. fmu_manipulation_toolbox/resources/help.png +0 -0
  92. fmu_manipulation_toolbox/resources/icon-round.png +0 -0
  93. fmu_manipulation_toolbox/resources/icon.png +0 -0
  94. fmu_manipulation_toolbox/resources/icon_fmu.png +0 -0
  95. fmu_manipulation_toolbox/resources/license.txt +34 -0
  96. fmu_manipulation_toolbox/resources/linux32/client_sm.so +0 -0
  97. fmu_manipulation_toolbox/resources/linux32/server_sm +0 -0
  98. fmu_manipulation_toolbox/resources/linux64/client_sm.so +0 -0
  99. fmu_manipulation_toolbox/resources/linux64/container.so +0 -0
  100. fmu_manipulation_toolbox/resources/linux64/server_sm +0 -0
  101. fmu_manipulation_toolbox/resources/mask.png +0 -0
  102. fmu_manipulation_toolbox/resources/radio-checked-disabled.png +0 -0
  103. fmu_manipulation_toolbox/resources/radio-checked-hover.png +0 -0
  104. fmu_manipulation_toolbox/resources/radio-checked.png +0 -0
  105. fmu_manipulation_toolbox/resources/radio-unchecked-disabled.png +0 -0
  106. fmu_manipulation_toolbox/resources/radio-unchecked-hover.png +0 -0
  107. fmu_manipulation_toolbox/resources/radio-unchecked.png +0 -0
  108. fmu_manipulation_toolbox/resources/win32/client_sm.dll +0 -0
  109. fmu_manipulation_toolbox/resources/win32/server_sm.exe +0 -0
  110. fmu_manipulation_toolbox/resources/win64/client_sm.dll +0 -0
  111. fmu_manipulation_toolbox/resources/win64/container.dll +0 -0
  112. fmu_manipulation_toolbox/resources/win64/server_sm.exe +0 -0
  113. fmu_manipulation_toolbox/split.py +708 -0
  114. fmu_manipulation_toolbox/terminals.py +156 -0
  115. fmu_manipulation_toolbox/textfiles.py +41 -0
  116. fmu_manipulation_toolbox/version.py +15 -0
  117. fmu_manipulation_toolbox-0.0.dev3.dist-info/METADATA +369 -0
  118. fmu_manipulation_toolbox-0.0.dev3.dist-info/RECORD +122 -0
  119. fmu_manipulation_toolbox-0.0.dev3.dist-info/WHEEL +5 -0
  120. fmu_manipulation_toolbox-0.0.dev3.dist-info/entry_points.txt +12 -0
  121. fmu_manipulation_toolbox-0.0.dev3.dist-info/licenses/LICENSE.txt +22 -0
  122. fmu_manipulation_toolbox-0.0.dev3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1033 @@
1
+ import csv
2
+ import json
3
+ import logging
4
+ import os
5
+ from typing import *
6
+ from pathlib import Path
7
+ import uuid
8
+ import xml.etree.ElementTree as ET
9
+ import zipfile
10
+
11
+ from .container import FMUContainer
12
+ from .textfiles import ENCODING, open_text
13
+
14
+ logger = logging.getLogger("fmu_manipulation_toolbox")
15
+
16
+
17
+ def _collect_fmu_names(data: Dict[str, Any], acc: Set[str]) -> None:
18
+ """Recursively collect every FMU filename referenced by `data["fmu"]`
19
+ (including nested containers)."""
20
+ for name in data.get("fmu", []):
21
+ acc.add(name)
22
+ for child in data.get("container", []):
23
+ _collect_fmu_names(child, acc)
24
+
25
+
26
+ def _relativize_json(data: Dict[str, Any], base_dir: Path) -> Dict[str, Any]:
27
+ """Rewrite every absolute FMU path referenced in a JSON-encoded assembly
28
+ `data` so that it becomes relative to `base_dir`.
29
+
30
+ Only entries in `data["fmu"]` that are absolute paths are considered
31
+ (container/sub-container names are plain identifiers, not filesystem
32
+ paths, and are left untouched). All other blocks (`input`, `output`,
33
+ `link`, `start`, `drop`) reference the very same FMU names and are
34
+ rewritten using the same mapping.
35
+ """
36
+ names: Set[str] = set()
37
+ _collect_fmu_names(data, names)
38
+
39
+ path_map: Dict[str, str] = {}
40
+ for name in names:
41
+ if not Path(name).is_absolute():
42
+ continue
43
+ try:
44
+ relative = os.path.relpath(name, base_dir)
45
+ except ValueError:
46
+ # e.g. paths on different drives on Windows: cannot be made relative.
47
+ logger.warning(f"Cannot make FMU path relative to '{base_dir}': '{name}'. Keeping it absolute.")
48
+ continue
49
+ path_map[name] = Path(relative).as_posix()
50
+
51
+ if path_map:
52
+ _remap_fmu_refs(data, path_map)
53
+ return data
54
+
55
+
56
+ def _basename_json(data: Dict[str, Any]) -> Dict[str, Any]:
57
+ """Rewrite every FMU path referenced in a JSON-encoded assembly `data` so
58
+ that only its filename (basename) is kept, discarding any directory
59
+ information.
60
+
61
+ Useful when the JSON's own physical location is not meaningful (e.g. a
62
+ documentation copy written to a temporary directory before being embedded
63
+ in a built FMU): a relative-to-JSON path would be confusing once the
64
+ temporary directory is gone, whereas a bare filename always stays valid.
65
+ """
66
+ names: Set[str] = set()
67
+ _collect_fmu_names(data, names)
68
+
69
+ path_map = {name: Path(name).name for name in names}
70
+ if path_map:
71
+ _remap_fmu_refs(data, path_map)
72
+ return data
73
+
74
+
75
+ def _remap_fmu_refs(data: Dict[str, Any], path_map: Dict[str, str]) -> None:
76
+ """Recursively rewrite FMU name references in a JSON-encoded assembly
77
+ dict, using `path_map` (old name -> new name)."""
78
+ if "fmu" in data:
79
+ data["fmu"] = [path_map.get(name, name) for name in data["fmu"]]
80
+
81
+ if "input" in data:
82
+ data["input"] = [[source, path_map.get(fmu_name, fmu_name), port_name]
83
+ for source, fmu_name, port_name in data["input"]]
84
+
85
+ if "output" in data:
86
+ data["output"] = [[path_map.get(fmu_name, fmu_name), port_name, target]
87
+ for fmu_name, port_name, target in data["output"]]
88
+
89
+ if "link" in data:
90
+ data["link"] = [[path_map.get(from_fmu, from_fmu), from_port,
91
+ path_map.get(to_fmu, to_fmu), to_port]
92
+ for from_fmu, from_port, to_fmu, to_port in data["link"]]
93
+
94
+ if "start" in data:
95
+ data["start"] = [[path_map.get(fmu_name, fmu_name), port_name, value]
96
+ for fmu_name, port_name, value in data["start"]]
97
+
98
+ if "drop" in data:
99
+ data["drop"] = [[path_map.get(fmu_name, fmu_name), port_name]
100
+ for fmu_name, port_name in data["drop"]]
101
+
102
+ for child in data.get("container", []):
103
+ _remap_fmu_refs(child, path_map)
104
+
105
+
106
+ class Port:
107
+ """Represents a port of an embedded FMU, identified by its FMU name and port name.
108
+
109
+ Attributes:
110
+ fmu_name (str): Filename of the FMU containing this port.
111
+ port_name (str): Name of the port within the FMU.
112
+ """
113
+
114
+ def __init__(self, fmu_name: str, port_name: str):
115
+ self.fmu_name = fmu_name
116
+ self.port_name = port_name
117
+
118
+ def __hash__(self):
119
+ return hash(f"{self.fmu_name}/{self.port_name}")
120
+
121
+ def __eq__(self, other):
122
+ return str(self) == str(other)
123
+
124
+ def __str__(self):
125
+ return f"{self.fmu_name}/{self.port_name}"
126
+
127
+
128
+ class Connection:
129
+ """Represents a directional connection between two ports.
130
+
131
+ Attributes:
132
+ from_port (Port): Source port of the connection.
133
+ to_port (Port): Destination port of the connection.
134
+ """
135
+
136
+ def __init__(self, from_port: Port, to_port: Port):
137
+ self.from_port = from_port
138
+ self.to_port = to_port
139
+
140
+ def __str__(self):
141
+ return f"{self.from_port} -> {self.to_port}"
142
+
143
+
144
+ class AssemblyNode:
145
+ """Represents a node in the assembly tree, defining a container and its topology.
146
+
147
+ An `AssemblyNode` describes how to build a single FMU Container: which FMUs it
148
+ embeds, how their ports are connected (links), which ports are exposed as container
149
+ inputs/outputs, and which runtime options (multi-threading, profiling, etc.) are enabled.
150
+
151
+ Nodes can be nested via `add_sub_node` to create hierarchical containers.
152
+
153
+ Attributes:
154
+ name (str | None): Output filename for the container (e.g. `"container.fmu"`).
155
+ step_size (float | None): Internal fixed time step in seconds, or `None` to deduce
156
+ from embedded FMUs.
157
+ mt (bool): Whether multithreaded mode is enabled.
158
+ profiling (bool): Whether profiling mode is enabled.
159
+ sequential (bool): Whether sequential scheduling is used.
160
+ auto_link (bool): Automatically link ports with matching names and types.
161
+ auto_input (bool): Automatically expose unconnected input ports.
162
+ auto_output (bool): Automatically expose unconnected output ports.
163
+ auto_parameter (bool): Automatically expose parameters of embedded FMUs.
164
+ auto_local (bool): Automatically expose local variables of embedded FMUs.
165
+ ts_multiplier (bool): Add a `TS_MULTIPLIER` input port to control step size dynamically.
166
+ parent (AssemblyNode | None): Parent node in a hierarchical assembly, or `None` for root.
167
+ children (dict[str, AssemblyNode]): Sub-container nodes, keyed by name.
168
+ fmu_names_list (list[str]): Ordered list of embedded FMU filenames.
169
+ input_ports (dict[Port, str]): Mapping from destination port to exposed input name.
170
+ output_ports (dict[Port, str]): Mapping from source port to exposed output name.
171
+ start_values (dict[Port, str]): Mapping from port to its start value.
172
+ drop_ports (list[Port]): List of output ports to explicitly ignore.
173
+ links (list[Connection]): List of connections between embedded FMUs.
174
+ """
175
+
176
+ def __init__(self, name: str, step_size: float = None, mt=False, profiling=False, sequential=False,
177
+ auto_link=True, auto_input=True, auto_output=True, auto_parameter=False, auto_local=False,
178
+ ts_multiplier=False):
179
+ self.name = name
180
+ if step_size:
181
+ try:
182
+ self.step_size = float(step_size)
183
+ except ValueError:
184
+ logger.warning(f"Step size '{step_size}' is incorrect format.")
185
+ self.step_size = None
186
+ else:
187
+ self.step_size = None
188
+ self.mt = mt
189
+ self.profiling = profiling
190
+ self.sequential = sequential
191
+ self.auto_link = auto_link
192
+ self.auto_input = auto_input
193
+ self.auto_output = auto_output
194
+ self.auto_parameter = auto_parameter
195
+ self.auto_local = auto_local
196
+ self.ts_multiplier = ts_multiplier
197
+
198
+ self.parent: Optional[AssemblyNode] = None
199
+ self.children: Dict[str, AssemblyNode] = {} # sub-containers
200
+ self.fmu_names_list: List[str] = [] # FMUs contained at this level (ordered list)
201
+ self.input_ports: Dict[Port, str] = {} # value is input port name, key is the source
202
+ self.output_ports: Dict[Port, str] = {} # value is output port name, key is the origin
203
+ self.start_values: Dict[Port, str] = {}
204
+ self.drop_ports: List[Port] = []
205
+ self.links: List[Connection] = []
206
+
207
+ def add_sub_node(self, sub_node):
208
+ """Add a child `AssemblyNode` to create a hierarchical (nested) container.
209
+
210
+ Args:
211
+ sub_node (AssemblyNode): The child node to add. Its name will be
212
+ auto-generated if `None`.
213
+
214
+ Raises:
215
+ AssemblyError: If the sub-node is already parented or already a child
216
+ of this node.
217
+ """
218
+ if sub_node.name is None:
219
+ sub_node.name = str(uuid.uuid4())+".fmu"
220
+
221
+ if sub_node.parent is not None:
222
+ raise AssemblyError(f"Internal Error: AssemblyNode {sub_node.name} is already parented.")
223
+
224
+ if sub_node.name in self.children:
225
+ raise AssemblyError(f"Internal Error: AssemblyNode {sub_node.name} is already child of {self.name}")
226
+
227
+ sub_node.parent = self
228
+ self.children[sub_node.name] = sub_node
229
+
230
+ def add_fmu(self, fmu_name: str):
231
+ """Declare an embedded FMU by filename.
232
+
233
+ Args:
234
+ fmu_name (str): Filename of the FMU to embed (e.g. `"model.fmu"`).
235
+ """
236
+ if fmu_name not in self.fmu_names_list:
237
+ self.fmu_names_list.append(fmu_name)
238
+
239
+ def add_input(self, from_port_name: str, to_fmu_filename: str, to_port_name: str):
240
+ """Expose a port of an embedded FMU as a container input.
241
+
242
+ Args:
243
+ from_port_name (str): Name of the exposed input on the container.
244
+ to_fmu_filename (str): Filename of the embedded FMU.
245
+ to_port_name (str): Name of the input port on the embedded FMU.
246
+ """
247
+ self.input_ports[Port(to_fmu_filename, to_port_name)] = from_port_name
248
+
249
+ def add_output(self, from_fmu_filename: str, from_port_name: str, to_port_name: str):
250
+ """Expose a port of an embedded FMU as a container output.
251
+
252
+ Args:
253
+ from_fmu_filename (str): Filename of the embedded FMU.
254
+ from_port_name (str): Name of the output port on the embedded FMU.
255
+ to_port_name (str): Name of the exposed output on the container.
256
+ """
257
+ self.output_ports[Port(from_fmu_filename, from_port_name)] = to_port_name
258
+
259
+ def add_drop_port(self, fmu_filename: str, port_name: str):
260
+ """Mark an output port to be explicitly ignored.
261
+
262
+ Args:
263
+ fmu_filename (str): Filename of the embedded FMU.
264
+ port_name (str): Name of the output port to drop.
265
+ """
266
+ self.drop_ports.append(Port(fmu_filename, port_name))
267
+
268
+ def add_link(self, from_fmu_filename: str, from_port_name: str, to_fmu_filename: str, to_port_name: str):
269
+ """Connect an output port of one embedded FMU to an input port of another.
270
+
271
+ Args:
272
+ from_fmu_filename (str): Filename of the source FMU.
273
+ from_port_name (str): Name of the output port on the source FMU.
274
+ to_fmu_filename (str): Filename of the destination FMU.
275
+ to_port_name (str): Name of the input port on the destination FMU.
276
+ """
277
+ self.links.append(Connection(Port(from_fmu_filename, from_port_name),
278
+ Port(to_fmu_filename, to_port_name)))
279
+
280
+ def add_start_value(self, fmu_filename: str, port_name: str, value: str):
281
+ """Set a start value for a port of an embedded FMU.
282
+
283
+ Args:
284
+ fmu_filename (str): Filename of the embedded FMU.
285
+ port_name (str): Name of the port.
286
+ value (str): Start value as a string.
287
+ """
288
+ self.start_values[Port(fmu_filename, port_name)] = value
289
+
290
+ def make_fmu(self, fmu_directory: Path, debug=False, description_pathname=None, fmi_version=2, datalog=False,
291
+ filename=None):
292
+ """Build the FMU Container.
293
+
294
+ Recursively builds any child containers first, then creates the container FMU
295
+ by delegating to
296
+ [FMUContainer][fmu_manipulation_toolbox.container.FMUContainer].
297
+
298
+ Args:
299
+ fmu_directory (Path): Directory containing the source FMUs and where the
300
+ container will be generated.
301
+ debug (bool): If `True`, keep intermediate build artifacts and enable
302
+ verbose logging.
303
+ description_pathname (Path | None): Path to the original description file
304
+ to embed in the FMU.
305
+ fmi_version (int): FMI version for the container interface (`2` or `3`).
306
+ datalog (bool): If `True`, generate a datalog configuration file inside
307
+ the container.
308
+ filename (str | None): Override the output filename. Defaults to `name`.
309
+ """
310
+ for node in self.children.values():
311
+ node.make_fmu(fmu_directory, debug=debug, fmi_version=fmi_version)
312
+
313
+ identifier = str(Path(self.name).stem)
314
+ container = FMUContainer(identifier, fmu_directory, description_pathname=description_pathname,
315
+ fmi_version=fmi_version)
316
+
317
+ for node in self.children.values():
318
+ container.get_fmu(node.name)
319
+
320
+ for fmu_name in self.fmu_names_list:
321
+ container.get_fmu(fmu_name)
322
+
323
+ for port, source in self.input_ports.items():
324
+ container.add_input(source, port.fmu_name, port.port_name)
325
+
326
+ for port, target in self.output_ports.items():
327
+ container.add_output(port.fmu_name, port.port_name, target)
328
+
329
+ for link in self.links:
330
+ container.add_link(link.from_port.fmu_name, link.from_port.port_name,
331
+ link.to_port.fmu_name, link.to_port.port_name)
332
+
333
+ for drop in self.drop_ports:
334
+ container.drop_port(drop.fmu_name, drop.port_name)
335
+
336
+ for port, value in self.start_values.items():
337
+ container.add_start_value(port.fmu_name, port.port_name, value)
338
+
339
+ wired = container.add_implicit_rule(auto_input=self.auto_input,
340
+ auto_output=self.auto_output,
341
+ auto_link=self.auto_link,
342
+ auto_parameter=self.auto_parameter,
343
+ auto_local=self.auto_local)
344
+ for input_rule in wired.rule_input:
345
+ self.add_input(input_rule[0], input_rule[1], input_rule[2])
346
+ for output_rule in wired.rule_output:
347
+ self.add_output(output_rule[0], output_rule[1], output_rule[2])
348
+ for link_rule in wired.rule_link:
349
+ self.add_link(link_rule[0], link_rule[1], link_rule[2], link_rule[3])
350
+
351
+ if filename is None:
352
+ filename = self.name
353
+
354
+ container.make_fmu(filename, self.step_size, mt=self.mt, profiling=self.profiling, sequential=self.sequential,
355
+ debug=debug, ts_multiplier=self.ts_multiplier, datalog=datalog)
356
+
357
+ for node in self.children.values():
358
+ logger.info(f"Deleting transient FMU Container '{node.name}'")
359
+ (fmu_directory / node.name).unlink()
360
+
361
+ def get_final_from(self, port: Port) -> Port:
362
+ """Resolve the ultimate source port by traversing the assembly hierarchy upward.
363
+
364
+ Args:
365
+ port (Port): The port to trace back to its origin.
366
+
367
+ Returns:
368
+ Port: The resolved source port (either a top-level input or an
369
+ embedded FMU port).
370
+
371
+ Raises:
372
+ AssemblyError: If the port is not connected upstream.
373
+ """
374
+ if port in self.input_ports:
375
+ ancestor = Port(self.name, self.input_ports[port])
376
+ if self.parent:
377
+ return self.parent.get_final_from(ancestor) # input port
378
+ else:
379
+ return ancestor # TOPLEVEL input port
380
+ elif port.fmu_name in self.fmu_names_list:
381
+ return port # embedded FMU
382
+ elif port.fmu_name in self.children:
383
+ child = self.children[port.fmu_name]
384
+ ancestors = [key for key, val in child.output_ports.items() if val == port.port_name]
385
+ if len(ancestors) == 1:
386
+ return child.get_final_from(ancestors[0]) # child output port
387
+
388
+ raise AssemblyError(f"{self.name}: Port {port} is not connected upstream.")
389
+
390
+ def get_final_to(self, port: Port) -> Port:
391
+ """Resolve the ultimate destination port by traversing the assembly hierarchy downward.
392
+
393
+ Args:
394
+ port (Port): The port to trace forward to its destination.
395
+
396
+ Returns:
397
+ Port: The resolved destination port (either a top-level output or an
398
+ embedded FMU port).
399
+
400
+ Raises:
401
+ AssemblyError: If the port is not connected downstream.
402
+ """
403
+ if port in self.output_ports:
404
+ successor = Port(self.name, self.output_ports[port])
405
+ if self.parent:
406
+ return self.parent.get_final_to(successor) # Output port
407
+ else:
408
+ return successor # TOPLEVEL output port
409
+ elif port.fmu_name in self.fmu_names_list:
410
+ return port # embedded FMU
411
+ elif port.fmu_name in self.children:
412
+ child = self.children[port.fmu_name]
413
+ successors = [key for key, val in child.input_ports.items() if val == port.port_name]
414
+ if len(successors) == 1:
415
+ return child.get_final_to(successors[0]) # Child input port
416
+
417
+ raise AssemblyError(f"Node {self.name}: Port {port} is not connected downstream.")
418
+
419
+ def get_fmu_connections(self, fmu_name: str) -> List[Connection]:
420
+ """Get all resolved connections involving a specific embedded FMU.
421
+
422
+ Returns connections where the given FMU is either source or destination,
423
+ with ports resolved through the full assembly hierarchy.
424
+
425
+ Args:
426
+ fmu_name (str): Filename of the embedded FMU.
427
+
428
+ Returns:
429
+ list[Connection]: List of connections involving the specified FMU.
430
+
431
+ Raises:
432
+ AssemblyError: If the FMU is not embedded in this node.
433
+ """
434
+ connections = []
435
+ if fmu_name not in self.fmu_names_list:
436
+ raise AssemblyError(f"Internal Error: FMU {fmu_name} is not embedded by {self.name}.")
437
+ for link in self.links:
438
+ if link.from_port.fmu_name == fmu_name:
439
+ connections.append(Connection(link.from_port, self.get_final_to(link.to_port)))
440
+ elif link.to_port.fmu_name == fmu_name:
441
+ connections.append(Connection(self.get_final_from(link.from_port), link.to_port))
442
+
443
+ for to_port, input_port_name in self.input_ports.items():
444
+ if to_port.fmu_name == fmu_name:
445
+ if self.parent:
446
+ connections.append(Connection(self.parent.get_final_from(Port(self.name, input_port_name)), to_port))
447
+ else:
448
+ connections.append(Connection(Port(self.name, input_port_name), to_port))
449
+
450
+ for from_port, output_port_name in self.output_ports.items():
451
+ if from_port.fmu_name == fmu_name:
452
+ if self.parent:
453
+ connections.append(Connection(from_port, self.parent.get_final_to(Port(self.name, output_port_name))))
454
+ else:
455
+ connections.append(Connection(from_port, Port(self.name, output_port_name))) ###HERE
456
+
457
+ return connections
458
+
459
+
460
+ class AssemblyError(Exception):
461
+ """Exception raised for errors during assembly parsing or container building.
462
+
463
+ Attributes:
464
+ reason (str): Human-readable description of the error.
465
+ """
466
+
467
+ def __init__(self, reason: str):
468
+ self.reason = reason
469
+
470
+ def __repr__(self):
471
+ return f"{self.reason}"
472
+
473
+
474
+ class Assembly:
475
+ """High-level interface for loading, manipulating, and building FMU Container assemblies.
476
+
477
+ `Assembly` reads a description file (CSV, JSON, or SSP), constructs the corresponding
478
+ [AssemblyNode][fmu_manipulation_toolbox.assembly.AssemblyNode] tree, and provides methods
479
+ to build the container FMU or export the assembly to a different format.
480
+
481
+ This is the main entry point for both the `fmucontainer` CLI and the Python API.
482
+
483
+ Examples:
484
+ ```python
485
+ from pathlib import Path
486
+ from fmu_manipulation_toolbox.assembly import Assembly
487
+
488
+ assembly = Assembly("bouncing.csv",
489
+ fmu_directory=Path("containers/bouncing_ball"),
490
+ mt=True)
491
+ assembly.make_fmu()
492
+ ```
493
+
494
+ Attributes:
495
+ filename (Path): Path to the description file.
496
+ fmu_directory (Path): Directory containing the source FMUs.
497
+ debug (bool): Whether debug mode is enabled.
498
+ root (AssemblyNode | None): Root node of the assembly tree.
499
+ """
500
+
501
+ def __init__(self, filename: Union[str, Path] = None, default_step_size=None, default_auto_link=True,
502
+ default_auto_input=True, debug=False, default_sequential=False, default_auto_output=True,
503
+ default_mt=False, default_profiling=False, fmu_directory: Path = Path("."),
504
+ default_auto_parameter=False, default_auto_local=False, default_ts_multiplier=False):
505
+ self.filename = Path(filename) if filename else None
506
+ self.default_auto_input = default_auto_input
507
+ self.debug = debug
508
+ self.default_auto_output = default_auto_output
509
+ self.default_step_size = default_step_size
510
+ self.default_auto_link = default_auto_link
511
+ self.default_auto_parameter = default_auto_parameter
512
+ self.default_auto_local = default_auto_local
513
+ self.default_mt = default_mt
514
+ self.default_sequential = default_sequential
515
+ self.default_profiling = default_profiling
516
+ self.default_ts_multiplier = default_ts_multiplier
517
+ self.fmu_directory = fmu_directory
518
+
519
+ if not fmu_directory.is_dir():
520
+ raise AssemblyError(f"FMU directory is not valid: '{fmu_directory}'")
521
+
522
+ self.root: Optional[AssemblyNode] = None
523
+
524
+ if self.filename:
525
+ self.input_pathname = fmu_directory / self.filename
526
+ self.description_pathname = self.input_pathname # For inclusion in FMU
527
+ self.read()
528
+ else:
529
+ self.description_pathname = None
530
+
531
+ def read(self):
532
+ """Read and parse the description file.
533
+
534
+ The format is determined by the file extension: `.json`, `.ssp`, or `.csv`.
535
+
536
+ Raises:
537
+ AssemblyError: If the file format is not supported.
538
+ """
539
+ if self.filename:
540
+ logger.info(f"Reading '{self.filename}'")
541
+ if self.filename.suffix == ".json":
542
+ self.read_json()
543
+ elif self.filename.suffix == ".ssp":
544
+ self.read_ssp()
545
+ elif self.filename.suffix == ".csv":
546
+ self.read_csv()
547
+ else:
548
+ raise AssemblyError(f"Not supported file format '{self.filename}")
549
+ else:
550
+ raise AssemblyError(f"Filename should be specified for reading.")
551
+
552
+ def write(self, filename: str):
553
+ """Export the assembly to a file.
554
+
555
+ Args:
556
+ filename (str): Output filename. The format is determined by the
557
+ extension (`.csv` or `.json`).
558
+
559
+ Raises:
560
+ AssemblyError: If the format is not supported.
561
+ """
562
+ if filename.endswith(".csv"):
563
+ self.write_csv(filename)
564
+ elif filename.endswith(".json"):
565
+ self.write_json(filename)
566
+ else:
567
+ raise AssemblyError(f"Unable to write to '{filename}': format unsupported.")
568
+
569
+ def read_csv(self):
570
+ """Parse a CSV description file and populate the assembly tree."""
571
+ name = str(self.filename.with_suffix(".fmu"))
572
+ self.root = AssemblyNode(name, step_size=self.default_step_size, auto_link=self.default_auto_link,
573
+ mt=self.default_mt, profiling=self.default_profiling,
574
+ sequential=self.default_sequential, auto_input=self.default_auto_input,
575
+ auto_output=self.default_auto_output, auto_parameter=self.default_auto_parameter,
576
+ auto_local=self.default_auto_local, ts_multiplier=self.default_ts_multiplier)
577
+
578
+ with open_text(self.input_pathname) as file:
579
+ reader = csv.reader(file, delimiter=';')
580
+ self._check_csv_headers(reader)
581
+ for i, row in enumerate(reader):
582
+ if not row or row[0][0] == '#': # skip blank line of comment
583
+ continue
584
+
585
+ try:
586
+ rule, from_fmu_filename, from_port_name, to_fmu_filename, to_port_name = row
587
+ except ValueError:
588
+ logger.error(f"Line #{i+2}: expecting 5 columns. Line skipped.")
589
+ continue
590
+
591
+ try:
592
+ self._read_csv_rule(self.root, rule.upper(),
593
+ from_fmu_filename, from_port_name, to_fmu_filename, to_port_name)
594
+ except AssemblyError as e:
595
+ logger.error(f"Line #{i+2}: {e}. Line skipped.")
596
+ continue
597
+
598
+ @staticmethod
599
+ def _check_csv_headers(reader):
600
+ headers = next(reader)
601
+ headers_lowered = [h.lower() for h in headers]
602
+ if not headers_lowered == ["rule", "from_fmu", "from_port", "to_fmu", "to_port"]:
603
+ raise AssemblyError("Header (1st line of the file) is not well formatted.")
604
+
605
+ @staticmethod
606
+ def _read_csv_rule(node: AssemblyNode, rule: str, from_fmu_filename: str, from_port_name: str,
607
+ to_fmu_filename: str, to_port_name: str):
608
+ if rule == "FMU":
609
+ if not from_fmu_filename:
610
+ raise AssemblyError("Missing FMU information.")
611
+ node.add_fmu(from_fmu_filename)
612
+
613
+ elif rule == "INPUT":
614
+ if not to_fmu_filename or not to_port_name:
615
+ raise AssemblyError("Missing INPUT ports information.")
616
+ if not from_port_name:
617
+ from_port_name = to_port_name
618
+ node.add_input(from_port_name, to_fmu_filename, to_port_name)
619
+
620
+ elif rule == "OUTPUT":
621
+ if not from_fmu_filename or not from_port_name:
622
+ raise AssemblyError("Missing OUTPUT ports information.")
623
+ if not to_port_name:
624
+ to_port_name = from_port_name
625
+ node.add_output(from_fmu_filename, from_port_name, to_port_name)
626
+
627
+ elif rule == "DROP":
628
+ if not from_fmu_filename or not from_port_name:
629
+ raise AssemblyError("Missing DROP ports information.")
630
+ node.add_drop_port(from_fmu_filename, from_port_name)
631
+
632
+ elif rule == "LINK":
633
+ node.add_link(from_fmu_filename, from_port_name, to_fmu_filename, to_port_name)
634
+
635
+ elif rule == "START":
636
+ if not from_fmu_filename or not from_port_name or not to_fmu_filename:
637
+ raise AssemblyError("Missing START ports information.")
638
+
639
+ node.add_start_value(from_fmu_filename, from_port_name, to_fmu_filename)
640
+ else:
641
+ raise AssemblyError(f"unexpected rule '{rule}'. Line skipped.")
642
+
643
+ def write_csv(self, filename: Union[str, Path]):
644
+ """Export the assembly as a CSV file.
645
+
646
+ Args:
647
+ filename (str | Path): Output filename, relative to `fmu_directory`.
648
+
649
+ Raises:
650
+ AssemblyError: If the assembly contains nested containers
651
+ (not representable in CSV).
652
+ """
653
+ if self.root.children:
654
+ raise AssemblyError("This assembly is not flat. Cannot export to CSV file.")
655
+
656
+ with open(self.fmu_directory / filename, "wt", encoding=ENCODING) as outfile:
657
+ outfile.write("rule;from_fmu;from_port;to_fmu;to_port\n")
658
+ for fmu in self.root.fmu_names_list:
659
+ outfile.write(f"FMU;{fmu};;;\n")
660
+ for port, source in self.root.input_ports.items():
661
+ outfile.write(f"INPUT;;{source};{port.fmu_name};{port.port_name}\n")
662
+ for port, target in self.root.output_ports.items():
663
+ outfile.write(f"OUTPUT;{port.fmu_name};{port.port_name};;{target}\n")
664
+ for link in self.root.links:
665
+ outfile.write(f"LINK;{link.from_port.fmu_name};{link.from_port.port_name};"
666
+ f"{link.to_port.fmu_name};{link.to_port.port_name}\n")
667
+ for port, value in self.root.start_values.items():
668
+ outfile.write(f"START;{port.fmu_name};{port.port_name};{value};\n")
669
+ for port in self.root.drop_ports:
670
+ outfile.write(f"DROP;{port.fmu_name};{port.port_name};;\n")
671
+
672
+ def read_json(self):
673
+ """Parse a JSON description file and populate the assembly tree.
674
+
675
+ Raises:
676
+ AssemblyError: If the JSON is malformed or contains invalid keywords.
677
+ """
678
+ with open_text(self.input_pathname) as file:
679
+ try:
680
+ data = json.load(file)
681
+ except json.decoder.JSONDecodeError as e:
682
+ raise AssemblyError(f"Cannot read json: {e}")
683
+ self.root = self._json_decode_node(data)
684
+ if not self.root.name:
685
+ self.root.name = str(self.filename.with_suffix(".fmu").name)
686
+
687
+ def _json_decode_node(self, data: Dict) -> AssemblyNode:
688
+ name = data.get("name", None) # 1
689
+ mt = data.get("mt", self.default_mt) # 2
690
+ profiling = data.get("profiling", self.default_profiling) # 3
691
+ sequential = data.get("sequential", self.default_sequential) # 3b
692
+ auto_link = data.get("auto_link", self.default_auto_link) # 4
693
+ auto_input = data.get("auto_input", self.default_auto_input) # 5
694
+ auto_output = data.get("auto_output", self.default_auto_output) # 6
695
+ auto_parameter = data.get("auto_parameter", self.default_auto_parameter) # 6b
696
+ auto_local = data.get("auto_local", self.default_auto_local) # 6c
697
+ step_size = data.get("step_size", self.default_step_size) # 7
698
+ ts_multiplier = data.get("ts_multiplier", self.default_ts_multiplier) # 7b
699
+
700
+ node = AssemblyNode(name, step_size=step_size, auto_link=auto_link, mt=mt, profiling=profiling,
701
+ sequential=sequential,
702
+ auto_input=auto_input, auto_output=auto_output, auto_parameter=auto_parameter,
703
+ auto_local=auto_local, ts_multiplier=ts_multiplier)
704
+
705
+ for key, value in data.items():
706
+ if key in ('name', 'step_size', 'auto_link', 'auto_input', 'auto_output', 'mt', 'profiling', 'sequential',
707
+ 'auto_parameter', 'auto_local', 'ts_multiplier'):
708
+ continue # Already read
709
+
710
+ elif key == "container": # 8
711
+ if not isinstance(value, list):
712
+ raise AssemblyError("JSON: 'container' keyword should define a list.")
713
+ for sub_data in value:
714
+ node.add_sub_node(self._json_decode_node(sub_data))
715
+
716
+ elif key == "fmu": # 9
717
+ if not isinstance(value, list):
718
+ raise AssemblyError("JSON: 'fmu' keyword should define a list.")
719
+ for fmu in value:
720
+ node.add_fmu(fmu)
721
+
722
+ elif key == "input": # 10
723
+ self._json_decode_keyword('input', value, node.add_input)
724
+
725
+ elif key == "output": # 11
726
+ self._json_decode_keyword('output', value, node.add_output)
727
+
728
+ elif key == "link": # 12
729
+ self._json_decode_keyword('link', value, node.add_link)
730
+
731
+ elif key == "start": # 13
732
+ self._json_decode_keyword('start', value, node.add_start_value)
733
+
734
+ elif key == "drop": # 14
735
+ self._json_decode_keyword('drop', value, node.add_drop_port)
736
+
737
+ else:
738
+ logger.error(f"JSON: unexpected keyword {key}. Skipped.")
739
+
740
+ return node
741
+
742
+ @staticmethod
743
+ def _json_decode_keyword(keyword: str, value, function):
744
+ if not isinstance(value, list):
745
+ raise AssemblyError(f"JSON: '{keyword}' keyword should define a list.")
746
+ for line in value:
747
+ if not isinstance(line, list):
748
+ raise AssemblyError(f"JSON: unexpected '{keyword}' value: {line}.")
749
+ try:
750
+ function(*line)
751
+ except TypeError:
752
+ raise AssemblyError(f"JSON: '{keyword}' value does not contain right number of fields: {line}.")
753
+
754
+ def write_json(self, filename: Union[str, Path], basenames_only: bool = False):
755
+ """Export the assembly as a JSON file.
756
+
757
+ FMU paths referenced in the `fmu`, `link`, `input`, `output`, `start`
758
+ and `drop` blocks are made relative to the directory of the produced
759
+ JSON file, so that the assembly stays portable (e.g. if the FMUs are
760
+ stored alongside the JSON file, or in a sibling directory).
761
+
762
+ Args:
763
+ filename (str | Path): Output filename, relative to `fmu_directory`.
764
+ basenames_only (bool): If `True`, reference embedded FMUs only by
765
+ their filename (no directory), instead of computing a path
766
+ relative to the JSON's own directory. Useful when the JSON is
767
+ written to a location that has no lasting meaning (e.g. a
768
+ temporary directory used to embed a documentation copy inside
769
+ a built FMU).
770
+ """
771
+ output_pathname = self.fmu_directory / filename
772
+ with open(output_pathname, "wt", encoding=ENCODING) as file:
773
+ data = self.json_encode()
774
+ if basenames_only:
775
+ data = _basename_json(data)
776
+ else:
777
+ data = _relativize_json(data, output_pathname.resolve().parent)
778
+ json.dump(data, file, indent=2)
779
+
780
+ def json_encode(self) -> Dict[str, Any]:
781
+ """Export the assembly as a JSON file."""
782
+ if self.root:
783
+ return self._json_encode_node(self.root)
784
+ else:
785
+ return {}
786
+
787
+ def _json_encode_node(self, node: AssemblyNode) -> Dict[str, Any]:
788
+ json_node = dict()
789
+ json_node["name"] = node.name # 1
790
+ json_node["mt"] = node.mt # 2
791
+ json_node["profiling"] = node.profiling # 3
792
+ json_node["sequential"] = node.sequential # 3b
793
+ json_node["auto_link"] = node.auto_link # 4
794
+ json_node["auto_input"] = node.auto_input # 5
795
+ json_node["auto_output"] = node.auto_output # 6
796
+ json_node["auto_parameter"] = node.auto_parameter # 6b
797
+ json_node["auto_local"] = node.auto_local # 6c
798
+
799
+ if node.step_size:
800
+ json_node["step_size"] = node.step_size # 7
801
+
802
+ if node.ts_multiplier:
803
+ json_node["ts_multiplier"] = node.ts_multiplier # 7b
804
+
805
+ if node.children:
806
+ json_node["container"] = [self._json_encode_node(child) for child in node.children.values()] # 8
807
+
808
+ if node.fmu_names_list:
809
+ json_node["fmu"] = [f"{fmu_name}" for fmu_name in sorted(node.fmu_names_list)] # 9
810
+
811
+ if node.input_ports:
812
+ json_node["input"] = [[f"{source}", f"{port.fmu_name}", f"{port.port_name}"] # 10
813
+ for port, source in node.input_ports.items()]
814
+
815
+ if node.output_ports:
816
+ json_node["output"] = [[f"{port.fmu_name}", f"{port.port_name}", f"{target}"] # 11
817
+ for port, target in node.output_ports.items()]
818
+
819
+ if node.links:
820
+ json_node["link"] = [[f"{link.from_port.fmu_name}", f"{link.from_port.port_name}", # 12
821
+ f"{link.to_port.fmu_name}", f"{link.to_port.port_name}"]
822
+ for link in node.links]
823
+
824
+ if node.start_values:
825
+ json_node["start"] = [[f"{port.fmu_name}", f"{port.port_name}", value] # 13
826
+ for port, value in node.start_values.items()]
827
+
828
+ if node.drop_ports:
829
+ json_node["drop"] = [[f"{port.fmu_name}", f"{port.port_name}"] for port in node.drop_ports] # 14
830
+
831
+ return json_node
832
+
833
+ def read_ssp(self):
834
+ """Parse an SSP (System Structure and Parameterization) archive.
835
+
836
+ Extracts embedded FMUs and the `SystemStructure.ssd` file, then builds
837
+ the assembly tree from the SSD connections.
838
+
839
+ Warning:
840
+ This feature is in alpha stage.
841
+ """
842
+ logger.warning("This feature is ALPHA stage.")
843
+
844
+
845
+ with zipfile.ZipFile(self.fmu_directory / self.filename) as zin:
846
+ extract_folder = self.fmu_directory / self.filename.with_suffix(".dir")
847
+ extract_folder.mkdir(exist_ok=True)
848
+
849
+ for file in zin.filelist:
850
+ if file.filename.endswith(".fmu"):
851
+ # FMU are all stored in extract_folder without hierarchy
852
+ file.filename = str(Path(file.filename).name)
853
+ zin.extract(file, path=extract_folder)
854
+ logger.debug(f"Extracted: {file.filename} (into {extract_folder})")
855
+
856
+ self.description_pathname = None
857
+ if "SystemStructure.ssd" in zin.namelist():
858
+ sdd = SSDParser(zin, extract_folder.name, step_size=self.default_step_size, auto_link=False,
859
+ mt=self.default_mt, profiling=self.default_profiling,
860
+ auto_input=False, auto_output=False, auto_parameter=False)
861
+ self.root = sdd.parse("SystemStructure.ssd")
862
+ self.root.name = str(self.filename.with_suffix(".fmu"))
863
+ else:
864
+ raise AssemblyError(f"'SystemStructure.ssd' file not found in '{self.fmu_directory / self.filename}'")
865
+
866
+ def make_fmu(self, dump_json=False, fmi_version=2, datalog=False, filename=None):
867
+ """Build the FMU Container from the loaded assembly.
868
+
869
+ Args:
870
+ dump_json (bool): If `True`, also export a JSON dump of the assembly.
871
+ fmi_version (int): FMI version for the container interface (`2` or `3`).
872
+ datalog (bool): If `True`, enable data logging inside the container.
873
+ filename (str | None): Override the output filename.
874
+ """
875
+ self.root.make_fmu(self.fmu_directory, debug=self.debug, description_pathname=self.description_pathname,
876
+ fmi_version=fmi_version, datalog=datalog, filename=filename)
877
+ if dump_json:
878
+ dump_file = Path(self.input_pathname.stem + "-dump").with_suffix(".json")
879
+ logger.info(f"Dump Json '{dump_file}'")
880
+ self.write_json(dump_file)
881
+
882
+ def get_flat_links(self) -> List[List[str]]:
883
+ links_list = []
884
+
885
+ if self.root:
886
+ self._get_flat_links_recursive(self.root, links_list)
887
+
888
+ return links_list
889
+
890
+ def _get_flat_links_recursive(self, node: AssemblyNode, links_list, left_candidates = [], right_candidates = []):
891
+ # internal links
892
+ for connection in node.links:
893
+ links_list.append([connection.from_port.fmu_name, connection.from_port.port_name,
894
+ connection.to_port.fmu_name, connection.to_port.port_name])
895
+
896
+ # boundary links
897
+ right_candidates = []
898
+ for port, output_name, in node.output_ports.items():
899
+ right_candidates.append([port.fmu_name, port.port_name, node.name, output_name])
900
+
901
+ left_candidates = []
902
+ for port, input_name, in node.input_ports.items():
903
+ left_candidates.append([node.name, input_name, port.fmu_name, port.port_name])
904
+
905
+ # proceed the sub nodes
906
+ for child in node.children.values():
907
+ self._get_flat_links_recursive(child, links_list,
908
+ left_candidates=left_candidates, right_candidates=right_candidates)
909
+ # Resolve candidate links
910
+ for candidate in right_candidates:
911
+ #TODO
912
+ if candidate[2] == node.name:
913
+ links_list.append([candidate[0], candidate[1], node.name, candidate[3]])
914
+
915
+ for candidate in left_candidates:
916
+ #TODO
917
+ if candidate[0] == node.name:
918
+ links_list.append([candidate[0], candidate[1], node.name, candidate[3]])
919
+
920
+
921
+
922
+ class SSDParser:
923
+ """Parser for SSD (System Structure Description) files of SSP 1.0 archives.
924
+
925
+ Parses the XML structure of an `.ssd` file and builds the corresponding
926
+ [AssemblyNode][fmu_manipulation_toolbox.assembly.AssemblyNode] tree, including
927
+ embedded FMUs, connections, and hierarchical sub-systems.
928
+
929
+ Elements are recognized by their namespace (`SSD_NAMESPACE`), whatever the
930
+ prefix used in the file (`ssd:`, another one, or a default namespace).
931
+
932
+ Attributes:
933
+ node_stack (list[AssemblyNode]): Stack of nodes used during XML parsing
934
+ for tracking hierarchy.
935
+ root (AssemblyNode | None): Root node after parsing is complete.
936
+ fmu_filenames (dict[str, str]): Mapping from SSD component names to
937
+ FMU filenames.
938
+ """
939
+
940
+ SSD_NAMESPACE = "http://ssp-standard.org/SSP1/SystemStructureDescription"
941
+
942
+ def __init__(self, zin: zipfile.ZipFile, extract_folder_name: str, **kwargs):
943
+ self.zin = zin
944
+ self.extract_folder_name = extract_folder_name
945
+
946
+ self.node_stack: List[AssemblyNode] = []
947
+ self.root = None
948
+ self.fmu_filenames: Dict[str, str] = {} # Component name => FMU filename
949
+ self.node_attrs = kwargs
950
+
951
+ def parse(self, ssd_filename: str) -> AssemblyNode:
952
+ """Parse an SSD file and return the root assembly node.
953
+
954
+ Args:
955
+ ssd_filename (str): filename to the `.ssd` file.
956
+
957
+ Returns:
958
+ AssemblyNode: The root node representing the parsed system structure.
959
+
960
+ Raises:
961
+ AssemblyError: If the file is not well-formed, is not an SSP 1.0
962
+ system structure description, or refers to unknown elements.
963
+ """
964
+ logger.debug(f"Analysing {ssd_filename}")
965
+ prefix = f"{{{self.SSD_NAMESPACE}}}"
966
+ try:
967
+ with self.zin.open(ssd_filename) as file:
968
+ root_checked = False
969
+ for event, element in ET.iterparse(file, events=("start", "end")):
970
+ if not root_checked:
971
+ if element.tag != f"{prefix}SystemStructureDescription":
972
+ raise AssemblyError(f"'{ssd_filename}' is not an SSP 1.0 system structure description: "
973
+ f"root element is <{element.tag}>, expected "
974
+ f"<SystemStructureDescription> in namespace '{self.SSD_NAMESPACE}'")
975
+ root_checked = True
976
+ if not element.tag.startswith(prefix):
977
+ continue # other SSP namespaces (ssc:, ssv:...) and vendor annotations
978
+ tag = element.tag[len(prefix):]
979
+ if event == "start":
980
+ self.start_element(tag, element.attrib)
981
+ else:
982
+ self.end_element(tag)
983
+ except ET.ParseError as error:
984
+ raise AssemblyError(f"'{ssd_filename}' is not well-formed XML: {error}") from error
985
+ except KeyError as error:
986
+ raise AssemblyError(f"'{ssd_filename}': reference to an unknown element or missing attribute "
987
+ f"{error}") from error
988
+
989
+ if self.root is None:
990
+ raise AssemblyError(f"'{ssd_filename}': no <System> found in namespace '{self.SSD_NAMESPACE}'")
991
+ return self.root
992
+
993
+ def start_element(self, tag_name, attrs):
994
+ """Handle the start of an SSD element (`tag_name` without namespace, e.g. `"Connection"`)."""
995
+ if tag_name == 'Connection':
996
+ if 'startElement' in attrs:
997
+ if 'endElement' in attrs:
998
+ fmu_start = self.fmu_filenames[attrs['startElement']]
999
+ fmu_end = self.fmu_filenames[attrs['endElement']]
1000
+ self.node_stack[-1].add_link(fmu_start, attrs['startConnector'],
1001
+ fmu_end, attrs['endConnector'])
1002
+ else:
1003
+ fmu_start = self.fmu_filenames[attrs['startElement']]
1004
+ self.node_stack[-1].add_output(fmu_start, attrs['startConnector'],
1005
+ attrs['endConnector'])
1006
+ else:
1007
+ fmu_end = self.fmu_filenames[attrs['endElement']]
1008
+ self.node_stack[-1].add_input(attrs['startConnector'],
1009
+ fmu_end, attrs['endConnector'])
1010
+
1011
+ elif tag_name == 'System':
1012
+ logger.info(f"SSP System: {attrs['name']}")
1013
+ filename = attrs['name'] + ".fmu"
1014
+ self.fmu_filenames[attrs['name']] = filename
1015
+ node = AssemblyNode(filename, **self.node_attrs)
1016
+ if self.node_stack:
1017
+ self.node_stack[-1].add_sub_node(node)
1018
+ else:
1019
+ self.root = node
1020
+
1021
+ self.node_stack.append(node)
1022
+
1023
+ elif tag_name == 'Component':
1024
+ filename = f"{self.extract_folder_name}/{str(Path(attrs['source']).name)}"
1025
+ name = attrs['name']
1026
+ self.fmu_filenames[name] = filename
1027
+ self.node_stack[-1].add_fmu(filename)
1028
+ logger.debug(f"Component {name} => {filename}")
1029
+
1030
+ def end_element(self, tag_name):
1031
+ """Handle the end of an SSD element (`tag_name` without namespace)."""
1032
+ if tag_name == 'System':
1033
+ self.node_stack.pop()