Coverage for src/meshpy/four_c/input_file.py: 95%

197 statements  

« prev     ^ index     » next       coverage.py v7.9.0, created at 2025-06-13 04:26 +0000

1# The MIT License (MIT) 

2# 

3# Copyright (c) 2018-2025 MeshPy 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 Dict as _Dict 

30from typing import List as _List 

31from typing import Optional as _Optional 

32 

33from fourcipp.fourc_input import FourCInput as _FourCInput 

34 

35from meshpy.core.boundary_condition import BoundaryCondition as _BoundaryCondition 

36from meshpy.core.conf import mpy as _mpy 

37from meshpy.core.coupling import Coupling as _Coupling 

38from meshpy.core.function import Function as _Function 

39from meshpy.core.geometry_set import GeometrySet as _GeometrySet 

40from meshpy.core.geometry_set import GeometrySetNodes as _GeometrySetNodes 

41from meshpy.core.mesh import Mesh as _Mesh 

42from meshpy.core.nurbs_patch import NURBSPatch as _NURBSPatch 

43from meshpy.four_c.input_file_mappings import ( 

44 INPUT_FILE_MAPPINGS as _INPUT_FILE_MAPPINGS, 

45) 

46from meshpy.utils.environment import cubitpy_is_available as _cubitpy_is_available 

47from meshpy.utils.environment import get_git_data as _get_git_data 

48 

49if _cubitpy_is_available(): 

50 import cubitpy as _cubitpy 

51 

52 

53def get_geometry_set_indices_from_section( 

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

55) -> _Dict: 

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

57 values. 

58 

59 Args: 

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

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

62 dict with the keys should be returned. 

63 """ 

64 

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

66 for entry in section_list: 

67 id_geometry_set = entry["d_id"] 

68 index_node = entry["node_id"] - 1 

69 if id_geometry_set not in geometry_set_dict: 

70 geometry_set_dict[id_geometry_set] = [] 

71 if append_node_ids: 

72 geometry_set_dict[id_geometry_set].append(index_node) 

73 

74 return geometry_set_dict 

75 

76 

77def _dump_coupling(coupling): 

78 """Return the input file representation of the coupling condition.""" 

79 

80 # TODO: Move this to a better place / gather all dump functions for general 

81 # MeshPy items in a file or so. 

82 

83 if isinstance(coupling.data, dict): 

84 data = coupling.data 

85 else: 

86 # In this case we have to check which beams are connected to the node. 

87 # TODO: Coupling also makes sense for different beam types, this can 

88 # be implemented at some point. 

89 nodes = coupling.geometry_set.get_points() 

90 connected_elements = [ 

91 element for node in nodes for element in node.element_link 

92 ] 

93 element_types = {type(element) for element in connected_elements} 

94 if len(element_types) > 1: 

95 raise TypeError( 

96 f"Expected a single connected type of beam elements, got {element_types}" 

97 ) 

98 element_type = element_types.pop() 

99 if element_type.beam_type is _mpy.beam.kirchhoff: 

100 rotvec = {element.rotvec for element in connected_elements} 

101 if len(rotvec) > 1 or not rotvec.pop(): 

102 raise TypeError( 

103 "Couplings for Kirchhoff beams and rotvec==False not yet implemented." 

104 ) 

105 

106 data = element_type.get_coupling_dict(coupling.data) 

107 

108 return {"E": coupling.geometry_set.i_global, **data} 

109 

110 

111class InputFile(_FourCInput): 

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

113 

114 def __init__(self, sections=None): 

115 """Initialize the input file.""" 

116 

117 super().__init__(sections=sections) 

118 

119 # Contents of NOX xml file. 

120 self.nox_xml_contents = "" 

121 

122 # Register converters to directly convert non-primitive types 

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

124 self.type_converter.register_numpy_types() 

125 self.type_converter.register_type( 

126 _Function, lambda converter, obj: obj.i_global 

127 ) 

128 

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

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

131 

132 Args: 

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

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

135 """ 

136 

137 if isinstance(object_to_add, _Mesh): 

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

139 

140 else: 

141 super().combine_sections(object_to_add) 

142 

143 def dump( 

144 self, 

145 input_file_path: str | _Path, 

146 *, 

147 nox_xml_file: str | None = None, 

148 add_header_default: bool = True, 

149 add_header_information: bool = True, 

150 add_footer_application_script: bool = True, 

151 sort_sections=False, 

152 validate=True, 

153 validate_sections_only: bool = False, 

154 ): 

155 """Write the input file to disk. 

156 

157 Args: 

158 input_file_path: 

159 Path to the input file that should be created. 

160 nox_xml_file: 

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

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

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

164 add_header_default: 

165 Prepend the default MeshPy header comment to the input file. 

166 add_header_information: 

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

168 Contains creation date, git details of MeshPy, CubitPy and 

169 original application which created the input file if available. 

170 add_footer_application_script: 

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

172 comment at the end of the input file. 

173 sort_sections: 

174 Sort sections alphabetically with FourCIPP. 

175 validate: 

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

177 validate_sections_only: 

178 Validate each section independently. Required sections are no longer 

179 required, but the sections must be valid. 

180 """ 

181 

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

183 input_file_path = _Path(input_file_path) 

184 

185 if self.nox_xml_contents: 

186 if nox_xml_file is None: 

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

188 

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

190 

191 # Write the xml file to the disc. 

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

193 xml_file.write(self.nox_xml_contents) 

194 

195 # Add information header to the input file 

196 if add_header_information: 

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

198 

199 super().dump( 

200 input_file_path=input_file_path, 

201 sort_sections=sort_sections, 

202 validate=validate, 

203 validate_sections_only=validate_sections_only, 

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

205 ) 

206 

207 if add_header_default or add_footer_application_script: 

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

209 lines = input_file.readlines() 

210 

211 if add_header_default: 

212 lines = [ 

213 "# " + line + "\n" for line in _mpy.input_file_meshpy_header 

214 ] + lines 

215 

216 if add_footer_application_script: 

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

218 lines += self._get_application_script(application_path) 

219 

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

221 input_file.writelines(lines) 

222 

223 def add_mesh_to_input_file(self, mesh: _Mesh): 

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

225 

226 Args: 

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

228 """ 

229 

230 # Perform some checks on the mesh. 

231 if _mpy.check_overlapping_elements: 

232 mesh.check_overlapping_elements() 

233 

234 def _get_global_start_geometry_set(dictionary): 

235 """Get the indices for the first "real" MeshPy geometry sets.""" 

236 

237 start_indices_geometry_set = {} 

238 for geometry_type, section_name in _INPUT_FILE_MAPPINGS[ 

239 "geometry_sets" 

240 ].items(): 

241 max_geometry_set_id = 0 

242 if section_name in dictionary: 

243 section_list = dictionary[section_name] 

244 if len(section_list) > 0: 

245 geometry_set_dict = get_geometry_set_indices_from_section( 

246 section_list, append_node_ids=False 

247 ) 

248 max_geometry_set_id = max(geometry_set_dict.keys()) 

249 start_indices_geometry_set[geometry_type] = max_geometry_set_id 

250 return start_indices_geometry_set 

251 

252 def _get_global_start_node(): 

253 """Get the index for the first "real" MeshPy node.""" 

254 

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

256 

257 def _get_global_start_element(): 

258 """Get the index for the first "real" MeshPy element.""" 

259 

260 return sum( 

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

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

263 ) 

264 

265 def _get_global_start_material(): 

266 """Get the index for the first "real" MeshPy material. 

267 

268 We have to account for materials imported from yaml files 

269 that have arbitrary numbering. 

270 """ 

271 

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

273 max_material_id = 0 

274 section_name = "MATERIALS" 

275 if section_name in self.sections: 

276 for material in self.sections[section_name]: 

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

278 return max_material_id 

279 

280 def _get_global_start_function(): 

281 """Get the index for the first "real" MeshPy function.""" 

282 

283 max_function_id = 0 

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

285 if section_name.startswith("FUNCT"): 

286 max_function_id = max( 

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

288 ) 

289 return max_function_id 

290 

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

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

293 

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

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

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

297 

298 # Set the values for i_global. 

299 for i, item in enumerate(data_list): 

300 # TODO make i_global index-0 based 

301 item.i_global = i + 1 + start_index 

302 

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

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

305 

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

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

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

309 

310 # Set the values for i_global. 

311 i = start_index 

312 i_nurbs_patch = 0 

313 for item in element_list: 

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

315 # rest of the items 

316 # TODO make i_global index-0 based 

317 item.i_global = i + 1 

318 if isinstance(item, _NURBSPatch): 

319 item.n_nurbs_patch = i_nurbs_patch + 1 

320 offset = item.get_number_elements() 

321 i += offset 

322 i_nurbs_patch += 1 

323 else: 

324 i += 1 

325 

326 def _dump_mesh_items(section_name, data_list): 

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

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

329 

330 # Do not write section if no content is available 

331 if len(data_list) == 0: 

332 return 

333 

334 list = [] 

335 

336 for item in data_list: 

337 if ( 

338 isinstance(item, _GeometrySet) 

339 or isinstance(item, _GeometrySetNodes) 

340 or isinstance(item, _NURBSPatch) 

341 ): 

342 list.extend(item.dump_to_list()) 

343 elif hasattr(item, "dump_to_list"): 

344 list.append(item.dump_to_list()) 

345 elif isinstance(item, _BoundaryCondition): 

346 list.append( 

347 { 

348 "E": item.geometry_set.i_global, 

349 **item.data, 

350 } 

351 ) 

352 

353 elif isinstance(item, _Coupling): 

354 list.append(_dump_coupling(item)) 

355 else: 

356 raise TypeError(f"Could not dump {item}") 

357 

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

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

360 # the data types correctly. 

361 if section_name in self.sections: 

362 existing_entries = self.pop(section_name) 

363 existing_entries.extend(list) 

364 list = existing_entries 

365 

366 self.add({section_name: list}) 

367 

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

369 mesh.unlink_nodes() 

370 start_indices_geometry_set = _get_global_start_geometry_set(self.sections) 

371 mesh_sets = mesh.get_unique_geometry_sets( 

372 geometry_set_start_indices=start_indices_geometry_set 

373 ) 

374 

375 # Assign global indices to all entries. 

376 start_index_nodes = _get_global_start_node() 

377 _set_i_global(mesh.nodes, start_index=start_index_nodes) 

378 

379 start_index_elements = _get_global_start_element() 

380 _set_i_global_elements(mesh.elements, start_index=start_index_elements) 

381 

382 start_index_materials = _get_global_start_material() 

383 _set_i_global(mesh.materials, start_index=start_index_materials) 

384 

385 start_index_functions = _get_global_start_function() 

386 _set_i_global(mesh.functions, start_index=start_index_functions) 

387 

388 # Add material data to the input file. 

389 _dump_mesh_items("MATERIALS", mesh.materials) 

390 

391 # Add the functions. 

392 for function in mesh.functions: 

393 self.add({f"FUNCT{function.i_global}": function.data}) 

394 

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

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

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

398 def get_number_of_coupling_conditions(key): 

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

400 if (key, _mpy.geo.point) in mesh.boundary_conditions.keys(): 

401 return len(mesh.boundary_conditions[key, _mpy.geo.point]) 

402 else: 

403 return 0 

404 

405 if ( 

406 get_number_of_coupling_conditions(_mpy.bc.point_coupling) 

407 + get_number_of_coupling_conditions(_mpy.bc.point_coupling_penalty) 

408 > 0 

409 ): 

410 mesh.set_node_links() 

411 

412 # Add the boundary conditions. 

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

414 if len(bc_list) > 0: 

415 section_name = ( 

416 bc_key 

417 if isinstance(bc_key, str) 

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

419 ) 

420 _dump_mesh_items(section_name, bc_list) 

421 

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

423 for element in mesh.elements: 

424 element.dump_element_specific_section(self) 

425 

426 # Add the geometry sets. 

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

428 if len(item) > 0: 

429 _dump_mesh_items(_INPUT_FILE_MAPPINGS["geometry_sets"][geom_key], item) 

430 

431 # Add the nodes and elements. 

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

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

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

435 

436 def _get_header(self) -> dict: 

437 """Return the information header for the current MeshPy run. 

438 

439 Returns: 

440 A dictionary with the header information. 

441 """ 

442 

443 header: dict = {"MeshPy": {}} 

444 

445 header["MeshPy"]["creation_date"] = _datetime.now().isoformat( 

446 sep=" ", timespec="seconds" 

447 ) 

448 

449 # application which created the input file 

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

451 header["MeshPy"]["Application"] = {"path": str(application_path)} 

452 

453 application_git_sha, application_git_date = _get_git_data( 

454 application_path.parent 

455 ) 

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

457 header["MeshPy"]["Application"].update( 

458 { 

459 "git_sha": application_git_sha, 

460 "git_date": application_git_date, 

461 } 

462 ) 

463 

464 # MeshPy information 

465 meshpy_git_sha, meshpy_git_date = _get_git_data( 

466 _Path(__file__).resolve().parent 

467 ) 

468 if meshpy_git_sha is not None and meshpy_git_date is not None: 

469 header["MeshPy"]["MeshPy"] = { 

470 "git_SHA": meshpy_git_sha, 

471 "git_date": meshpy_git_date, 

472 } 

473 

474 # CubitPy information 

475 if _cubitpy_is_available(): 

476 cubitpy_git_sha, cubitpy_git_date = _get_git_data( 

477 _os.path.dirname(_cubitpy.__file__) 

478 ) 

479 

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

481 header["MeshPy"]["CubitPy"] = { 

482 "git_SHA": cubitpy_git_sha, 

483 "git_date": cubitpy_git_date, 

484 } 

485 

486 return header 

487 

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

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

490 

491 Args: 

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

493 Returns: 

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

495 """ 

496 

497 application_script_lines = [ 

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

499 ] 

500 

501 with open(application_path) as script_file: 

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

503 

504 return application_script_lines