Coverage for src/beamme/four_c/input_file.py: 98%

176 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-29 11:30 +0000

1# The MIT License (MIT) 

2# 

3# Copyright (c) 2018-2025 BeamMe Authors 

4# 

5# Permission is hereby granted, free of charge, to any person obtaining a copy 

6# of this software and associated documentation files (the "Software"), to deal 

7# in the Software without restriction, including without limitation the rights 

8# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 

9# copies of the Software, and to permit persons to whom the Software is 

10# furnished to do so, subject to the following conditions: 

11# 

12# The above copyright notice and this permission notice shall be included in 

13# all copies or substantial portions of the Software. 

14# 

15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 

16# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 

17# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 

18# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 

19# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 

20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 

21# THE SOFTWARE. 

22"""This module defines the classes that are used to create an input file for 

234C.""" 

24 

25import os as _os 

26import sys as _sys 

27from datetime import datetime as _datetime 

28from pathlib import Path as _Path 

29from typing import Callable as _Callable 

30from typing import Dict as _Dict 

31from typing import List as _List 

32 

33from fourcipp.fourc_input import FourCInput as _FourCInput 

34from fourcipp.fourc_input import sort_by_section_names as _sort_by_section_names 

35 

36from beamme.core.conf import INPUT_FILE_HEADER as _INPUT_FILE_HEADER 

37from beamme.core.conf import bme as _bme 

38from beamme.core.function import Function as _Function 

39from beamme.core.material import Material as _Material 

40from beamme.core.mesh import Mesh as _Mesh 

41from beamme.core.node import Node as _Node 

42from beamme.core.nurbs_patch import NURBSPatch as _NURBSPatch 

43from beamme.four_c.input_file_dump_item import dump_item_to_list as _dump_item_to_list 

44from beamme.four_c.input_file_dump_item import ( 

45 dump_item_to_section as _dump_item_to_section, 

46) 

47from beamme.four_c.input_file_mappings import ( 

48 INPUT_FILE_MAPPINGS as _INPUT_FILE_MAPPINGS, 

49) 

50from beamme.utils.environment import cubitpy_is_available as _cubitpy_is_available 

51from beamme.utils.environment import get_git_data as _get_git_data 

52 

53if _cubitpy_is_available(): 

54 import cubitpy as _cubitpy 

55 

56 

57def get_geometry_set_indices_from_section( 

58 section_list: _List, *, append_node_ids: bool = True 

59) -> _Dict: 

60 """Return a dictionary with the geometry set ID as keys and the node IDs as 

61 values. 

62 

63 Args: 

64 section_list: A list with the legacy strings for the geometry pair 

65 append_node_ids: If the node IDs shall be appended, or only the 

66 dict with the keys should be returned. 

67 """ 

68 

69 geometry_set_dict: _Dict[int, _List[int]] = {} 

70 for entry in section_list: 

71 id_geometry_set = entry["d_id"] 

72 index_node = entry["node_id"] - 1 

73 if id_geometry_set not in geometry_set_dict: 

74 geometry_set_dict[id_geometry_set] = [] 

75 if append_node_ids: 

76 geometry_set_dict[id_geometry_set].append(index_node) 

77 

78 return geometry_set_dict 

79 

80 

81class InputFile(_FourCInput): 

82 """An item that represents a complete 4C input file.""" 

83 

84 def __init__(self, sections=None): 

85 """Initialize the input file.""" 

86 

87 super().__init__(sections=sections) 

88 

89 # Contents of NOX xml file. 

90 self.nox_xml_contents = "" 

91 

92 # Register converters to directly convert non-primitive types 

93 # to native Python types via the FourCIPP type converter. 

94 self.type_converter.register_numpy_types() 

95 self.type_converter.register_type( 

96 (_Function, _Material, _Node), lambda converter, obj: obj.i_global + 1 

97 ) 

98 

99 def add(self, object_to_add, **kwargs): 

100 """Add a mesh or a dictionary to the input file. 

101 

102 Args: 

103 object: The object to be added. This can be a mesh or a dictionary. 

104 **kwargs: Additional arguments to be passed to the add method. 

105 """ 

106 

107 if isinstance(object_to_add, _Mesh): 

108 self.add_mesh_to_input_file(mesh=object_to_add, **kwargs) 

109 

110 else: 

111 super().combine_sections(object_to_add) 

112 

113 def dump( 

114 self, 

115 input_file_path: str | _Path, 

116 *, 

117 nox_xml_file: str | None = None, 

118 add_header_default: bool = True, 

119 add_header_information: bool = True, 

120 add_footer_application_script: bool = True, 

121 validate=True, 

122 validate_sections_only: bool = False, 

123 sort_function: _Callable[[dict], dict] | None = _sort_by_section_names, 

124 fourcipp_yaml_style: bool = True, 

125 ): 

126 """Write the input file to disk. 

127 

128 Args: 

129 input_file_path: 

130 Path to the input file that should be created. 

131 nox_xml_file: 

132 If this is a string, the NOX xml file will be created with this 

133 name. If this is None, the NOX xml file will be created with the 

134 name of the input file with the extension ".nox.xml". 

135 add_header_default: 

136 Prepend the default header comment to the input file. 

137 add_header_information: 

138 If the information header should be exported to the input file 

139 Contains creation date, git details of BeamMe, CubitPy and 

140 original application which created the input file if available. 

141 add_footer_application_script: 

142 Append the application script which creates the input files as a 

143 comment at the end of the input file. 

144 validate: 

145 Validate if the created input file is compatible with 4C with FourCIPP. 

146 validate_sections_only: 

147 Validate each section independently. Required sections are no longer 

148 required, but the sections must be valid. 

149 sort_function: 

150 A function which sorts the sections of the input file. 

151 fourcipp_yaml_style: 

152 If True, the input file is written in the fourcipp yaml style. 

153 """ 

154 

155 # Make sure the given input file is a Path instance. 

156 input_file_path = _Path(input_file_path) 

157 

158 if self.nox_xml_contents: 

159 if nox_xml_file is None: 

160 nox_xml_file = input_file_path.name.split(".")[0] + ".nox.xml" 

161 

162 self["STRUCT NOX/Status Test"] = {"XML File": nox_xml_file} 

163 

164 # Write the xml file to the disc. 

165 with open(input_file_path.parent / nox_xml_file, "w") as xml_file: 

166 xml_file.write(self.nox_xml_contents) 

167 

168 # Add information header to the input file 

169 if add_header_information: 

170 self.add({"TITLE": self._get_header()}) 

171 

172 super().dump( 

173 input_file_path=input_file_path, 

174 validate=validate, 

175 validate_sections_only=validate_sections_only, 

176 convert_to_native_types=False, # conversion already happens during add() 

177 sort_function=sort_function, 

178 use_fourcipp_yaml_style=fourcipp_yaml_style, 

179 ) 

180 

181 if add_header_default or add_footer_application_script: 

182 with open(input_file_path, "r") as input_file: 

183 lines = input_file.readlines() 

184 

185 if add_header_default: 

186 lines = ["# " + line + "\n" for line in _INPUT_FILE_HEADER] + lines 

187 

188 if add_footer_application_script: 

189 application_path = _Path(_sys.argv[0]).resolve() 

190 lines += self._get_application_script(application_path) 

191 

192 with open(input_file_path, "w") as input_file: 

193 input_file.writelines(lines) 

194 

195 def add_mesh_to_input_file(self, mesh: _Mesh): 

196 """Add a mesh to the input file. 

197 

198 Args: 

199 mesh: The mesh to be added to the input file. 

200 """ 

201 

202 # Perform some checks on the mesh. 

203 if _bme.check_overlapping_elements: 

204 mesh.check_overlapping_elements() 

205 

206 def _get_global_start_geometry_set(dictionary): 

207 """Get the indices for the first "real" BeamMe geometry sets.""" 

208 

209 start_indices_geometry_set = {} 

210 for geometry_type, section_name in _INPUT_FILE_MAPPINGS[ 

211 "geometry_sets_geometry_to_condition_name" 

212 ].items(): 

213 max_geometry_set_id = 0 

214 if section_name in dictionary: 

215 section_list = dictionary[section_name] 

216 if len(section_list) > 0: 

217 geometry_set_dict = get_geometry_set_indices_from_section( 

218 section_list, append_node_ids=False 

219 ) 

220 max_geometry_set_id = max(geometry_set_dict.keys()) 

221 start_indices_geometry_set[geometry_type] = max_geometry_set_id 

222 return start_indices_geometry_set 

223 

224 def _get_global_start_node(): 

225 """Get the index for the first "real" BeamMe node.""" 

226 

227 return len(self.sections.get("NODE COORDS", [])) 

228 

229 def _get_global_start_element(): 

230 """Get the index for the first "real" BeamMe element.""" 

231 

232 return sum( 

233 len(self.sections.get(section, [])) 

234 for section in ["FLUID ELEMENTS", "STRUCTURE ELEMENTS"] 

235 ) 

236 

237 def _get_global_start_material(): 

238 """Get the index for the first "real" BeamMe material. 

239 

240 We have to account for materials imported from yaml files 

241 that have arbitrary numbering. 

242 """ 

243 

244 # Get the maximum material index in materials imported from a yaml file 

245 max_material_id = 0 

246 section_name = "MATERIALS" 

247 if section_name in self.sections: 

248 for material in self.sections[section_name]: 

249 max_material_id = max(max_material_id, material["MAT"]) 

250 return max_material_id 

251 

252 def _get_global_start_function(): 

253 """Get the index for the first "real" BeamMe function.""" 

254 

255 max_function_id = 0 

256 for section_name in self.sections.keys(): 

257 if section_name.startswith("FUNCT"): 

258 max_function_id = max( 

259 max_function_id, int(section_name.split("FUNCT")[-1]) 

260 ) 

261 return max_function_id 

262 

263 def _set_i_global(data_list, *, start_index=0): 

264 """Set i_global in every item of data_list.""" 

265 

266 # A check is performed that every entry in data_list is unique. 

267 if len(data_list) != len(set(data_list)): 

268 raise ValueError("Elements in data_list are not unique!") 

269 

270 # Set the values for i_global. 

271 for i, item in enumerate(data_list): 

272 item.i_global = i + start_index 

273 

274 def _set_i_global_elements(element_list, *, start_index=0): 

275 """Set i_global in every item of element_list.""" 

276 

277 # A check is performed that every entry in element_list is unique. 

278 if len(element_list) != len(set(element_list)): 

279 raise ValueError("Elements in element_list are not unique!") 

280 

281 # Set the values for i_global. 

282 i = start_index 

283 i_nurbs_patch = 0 

284 for item in element_list: 

285 # As a NURBS patch can be defined with more elements, an offset is applied to the 

286 # rest of the items 

287 item.i_global = i 

288 if isinstance(item, _NURBSPatch): 

289 item.i_nurbs_patch = i_nurbs_patch 

290 offset = item.get_number_elements() 

291 i += offset 

292 i_nurbs_patch += 1 

293 else: 

294 i += 1 

295 

296 def _dump_mesh_items(section_name, data_list): 

297 """Output a section name and apply either the default dump or the 

298 specialized the dump_to_list for each list item.""" 

299 

300 # Do not write section if no content is available 

301 if len(data_list) == 0: 

302 return 

303 

304 list = [] 

305 

306 for item in data_list: 

307 _dump_item_to_list(list, item) 

308 

309 # If section already exists, retrieve from input file and 

310 # add newly. We always need to go through fourcipp to convert 

311 # the data types correctly. 

312 if section_name in self.sections: 

313 existing_entries = self.pop(section_name) 

314 existing_entries.extend(list) 

315 list = existing_entries 

316 

317 self.add({section_name: list}) 

318 

319 # Add sets from couplings and boundary conditions to a temp container. 

320 mesh.unlink_nodes() 

321 start_indices_geometry_set = _get_global_start_geometry_set(self.sections) 

322 mesh_sets = mesh.get_unique_geometry_sets( 

323 geometry_set_start_indices=start_indices_geometry_set 

324 ) 

325 

326 # Assign global indices to all entries. 

327 start_index_nodes = _get_global_start_node() 

328 _set_i_global(mesh.nodes, start_index=start_index_nodes) 

329 

330 start_index_elements = _get_global_start_element() 

331 _set_i_global_elements(mesh.elements, start_index=start_index_elements) 

332 

333 start_index_materials = _get_global_start_material() 

334 _set_i_global(mesh.materials, start_index=start_index_materials) 

335 

336 start_index_functions = _get_global_start_function() 

337 _set_i_global(mesh.functions, start_index=start_index_functions) 

338 

339 # Add material data to the input file. 

340 _dump_mesh_items("MATERIALS", mesh.materials) 

341 

342 # Add the functions. 

343 for function in mesh.functions: 

344 self.add({f"FUNCT{function.i_global + 1}": function.data}) 

345 

346 # If there are couplings in the mesh, set the link between the nodes 

347 # and elements, so the couplings can decide which DOFs they couple, 

348 # depending on the type of the connected beam element. 

349 def get_number_of_coupling_conditions(key): 

350 """Return the number of coupling conditions in the mesh.""" 

351 if (key, _bme.geo.point) in mesh.boundary_conditions.keys(): 

352 return len(mesh.boundary_conditions[key, _bme.geo.point]) 

353 else: 

354 return 0 

355 

356 if ( 

357 get_number_of_coupling_conditions(_bme.bc.point_coupling) 

358 + get_number_of_coupling_conditions(_bme.bc.point_coupling_penalty) 

359 > 0 

360 ): 

361 mesh.set_node_links() 

362 

363 # Add the boundary conditions. 

364 for (bc_key, geom_key), bc_list in mesh.boundary_conditions.items(): 

365 if len(bc_list) > 0: 

366 section_name = ( 

367 bc_key 

368 if isinstance(bc_key, str) 

369 else _INPUT_FILE_MAPPINGS["boundary_conditions"][(bc_key, geom_key)] 

370 ) 

371 _dump_mesh_items(section_name, bc_list) 

372 

373 # Add additional element sections, e.g., for NURBS knot vectors. 

374 for element in mesh.elements: 

375 _dump_item_to_section(self, element) 

376 

377 # Add the geometry sets. 

378 for geom_key, item in mesh_sets.items(): 

379 if len(item) > 0: 

380 _dump_mesh_items( 

381 _INPUT_FILE_MAPPINGS["geometry_sets_geometry_to_condition_name"][ 

382 geom_key 

383 ], 

384 item, 

385 ) 

386 

387 # Add the nodes and elements. 

388 _dump_mesh_items("NODE COORDS", mesh.nodes) 

389 _dump_mesh_items("STRUCTURE ELEMENTS", mesh.elements) 

390 # TODO: reset all links and counters set in this method. 

391 

392 def _get_header(self) -> dict: 

393 """Return the information header for the current BeamMe run. 

394 

395 Returns: 

396 A dictionary with the header information. 

397 """ 

398 

399 header: dict = {"BeamMe": {}} 

400 

401 header["BeamMe"]["creation_date"] = _datetime.now().isoformat( 

402 sep=" ", timespec="seconds" 

403 ) 

404 

405 # application which created the input file 

406 application_path = _Path(_sys.argv[0]).resolve() 

407 header["BeamMe"]["Application"] = {"path": str(application_path)} 

408 

409 application_git_sha, application_git_date = _get_git_data( 

410 application_path.parent 

411 ) 

412 if application_git_sha is not None and application_git_date is not None: 

413 header["BeamMe"]["Application"].update( 

414 { 

415 "git_sha": application_git_sha, 

416 "git_date": application_git_date, 

417 } 

418 ) 

419 

420 # BeamMe information 

421 beamme_git_sha, beamme_git_date = _get_git_data( 

422 _Path(__file__).resolve().parent 

423 ) 

424 if beamme_git_sha is not None and beamme_git_date is not None: 

425 header["BeamMe"]["BeamMe"] = { 

426 "git_SHA": beamme_git_sha, 

427 "git_date": beamme_git_date, 

428 } 

429 

430 # CubitPy information 

431 if _cubitpy_is_available(): 

432 cubitpy_git_sha, cubitpy_git_date = _get_git_data( 

433 _os.path.dirname(_cubitpy.__file__) 

434 ) 

435 

436 if cubitpy_git_sha is not None and cubitpy_git_date is not None: 

437 header["BeamMe"]["CubitPy"] = { 

438 "git_SHA": cubitpy_git_sha, 

439 "git_date": cubitpy_git_date, 

440 } 

441 

442 return header 

443 

444 def _get_application_script(self, application_path: _Path) -> list[str]: 

445 """Get the script that created this input file. 

446 

447 Args: 

448 application_path: Path to the script that created this input file. 

449 Returns: 

450 A list of strings with the script that created this input file. 

451 """ 

452 

453 application_script_lines = [ 

454 "# Application script which created this input file:\n" 

455 ] 

456 

457 with open(application_path) as script_file: 

458 application_script_lines.extend("# " + line for line in script_file) 

459 

460 return application_script_lines