diff --git a/SU2_PY/SU2_Nastran/pysu2_nastran.py b/SU2_PY/SU2_Nastran/pysu2_nastran.py index 9d1b993b054..f649ff96a21 100644 --- a/SU2_PY/SU2_Nastran/pysu2_nastran.py +++ b/SU2_PY/SU2_Nastran/pysu2_nastran.py @@ -29,6 +29,8 @@ # Imports # ---------------------------------------------------------------------- +import re + import numpy as np import scipy.linalg as linalg from math import * @@ -505,9 +507,9 @@ def nastran_float(s): BZ = nastran_float(line[64:72]) z_direction = np.array([BX - AX, BY - AY, BZ - AZ]) z_direction = z_direction / linalg.norm(z_direction) - line = meshfile.readline() - line = line.strip("\r\n") - line = line[30:] + line = self.__readContinuationLine( + meshfile, "CORD2R {}".format(CID) + ) CX = nastran_float(line[8:16]) CY = nastran_float(line[16:24]) CZ = nastran_float(line[24:32]) @@ -524,32 +526,41 @@ def nastran_float(s): pos = line.find("SET1") if pos == 30: - line = line.strip("\r\n") - line = line[37:] - line = line.split() - existValue = True - markerTag = line.pop(0) + fields = line.strip("\r\n")[30:].split() + fields.pop(0) + markerTag = fields.pop(0) self.markers[markerTag] = [] - while existValue: - if line[0] == "+": - line = meshfile.readline() - line = line.strip("\r\n") - line = line[37:] - line = line.split() - ID = int(line.pop(0)) + + def addPoint(ID): for iPoint in range(self.nPoint): if self.node[iPoint].GetID() == ID: - break - if (iPoint == (self.nPoint - 1)) and ( - self.node[iPoint].GetID() != ID - ): - raise Exception( - "Point {} in the set {} was not found in the mesh".format( - ID, markerTag - ) + self.markers[markerTag].append(iPoint) + return + raise Exception( + "Point {} in the set {} was not found in the mesh".format( + ID, markerTag ) - self.markers[markerTag].append(iPoint) - existValue = len(line) >= 1 + ) + + ID = None + while fields: + entry = fields.pop(0) + if entry[0] in "+*" and not fields: + # continuation marker in field 10, the set goes on + fields = self.__readContinuationLine( + meshfile, "SET1 {}".format(markerTag) + ).split() + fields.pop(0) + elif entry == "THRU": + # ranges may not open or close a line (QRG SET1 remark 4) + last = ID + ID = int(fields.pop(0)) + for thruID in range(last + 1, ID): + addPoint(thruID) + addPoint(ID) + else: + ID = int(entry) + addPoint(ID) self.nMarker += 1 continue @@ -563,11 +574,37 @@ def nastran_float(s): print("Number of reference systems: {}".format(self.nRefSys)) print("Moving marker: {}".format(self.FSI_marker)) print( - "Number of points in the moving marker".format( + "Number of points in the moving marker: {}".format( len(self.markers[self.FSI_marker]) ) ) + def __readContinuationLine(self, meshfile, entry): + """ + This method returns the card image of the next continuation line, skipping + the page titles, carriage control characters and column rulers with which + the f06 file paginates the sorted bulk data echo. + """ + + while True: + line = meshfile.readline() + if not line: + raise Exception( + "Unexpected end of {} while reading a continuation of {}".format( + self.Mesh_file, entry + ) + ) + image = line.strip("\r\n")[30:] + fields = image.split() + if fields and fields[0][0] in "+*": + return image + if fields and re.match(r" *[0-9]+-", line): + raise Exception( + "Expected a continuation of {} but found {}".format( + entry, fields[0] + ) + ) + def __checkBlankField(self, string): """ This method considers that Nastran apply 0 when the reference system is not specified diff --git a/SU2_PY/SU2_Nastran/test_pysu2_nastran_continuations.py b/SU2_PY/SU2_Nastran/test_pysu2_nastran_continuations.py new file mode 100644 index 00000000000..26cc23e2264 --- /dev/null +++ b/SU2_PY/SU2_Nastran/test_pysu2_nastran_continuations.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python + +## \file test_pysu2_nastran_continuations.py +# \brief Tests for continuation and page break handling in the Nastran mesh reader. +# \version 8.5.0 "Harrier" +# +# SU2 Project Website: https://su2code.github.io +# +# The SU2 Project is maintained by the SU2 Foundation +# (http://su2foundation.org) +# +# Copyright 2012-2026, SU2 Contributors (cf. AUTHORS.md) +# +# SU2 is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# SU2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with SU2. If not, see . + +import os +import sys +import tempfile +import unittest + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from pysu2_nastran import Solver + +# Page header block interrupting the sorted bulk data echo, as printed by +# Nastran at every page break (structure taken from the f06 in issue #2313). +PAGE_HEADER = [ + "1 NX NASTRAN MODES ANALYSIS SET " + "NOVEMBER 15, 2022 MSC Nastran 11/19/16 PAGE 4257", + " " * 132, + "0" + " " * 131, + " " * 50 + "S O R T E D B U L K D A T A E C H O", + " " * 17 + "ENTRY", + " " * 17 + "COUNT . 1 .. 2 .. 3 .. 4 .. 5 .. 6 " + ".. 7 .. 8 .. 9 .. 10 .", +] + + +def echo(count, *fields): + """Builds one numbered line of the sorted bulk data echo.""" + + line = ("%6d-" % count).rjust(22) + " " * 8 + for field in fields: + line += str(field).ljust(8) + return line.rstrip() + + +def grid_lines(ids, start=1): + return [ + echo(start + i, "GRID", gid, 0, "1.0", "2.0", "3.0", 0) + for i, gid in enumerate(ids) + ] + + +def set1_lines(ids, start, markers): + """Echoes SET1 1 with the given continuation markers between chunks.""" + + chunks = [ids[:7]] + [ids[i : i + 8] for i in range(7, len(ids), 8)] + lines = [] + for i, chunk in enumerate(chunks): + head = ["SET1", 1] if i == 0 else [markers[i - 1]] + tail = [markers[i]] if i < len(chunks) - 1 else [] + lines.append(echo(start + i, *(head + list(chunk) + tail))) + return lines + + +class TestContinuations(unittest.TestCase): + def read(self, lines, marker="1"): + handle, path = tempfile.mkstemp(suffix=".f06") + with os.fdopen(handle, "w") as mesh_file: + mesh_file.write("\n".join(lines) + "\n") + self.addCleanup(os.remove, path) + solver = Solver.__new__(Solver) + solver.Mesh_file = path + solver.FSI_marker = marker + solver.node = [] + solver.markers = {} + solver.refsystems = [] + solver._Solver__readNastranMesh() + return solver + + def marker_ids(self, solver, marker="1"): + return sorted(solver.node[i].GetID() for i in solver.markers[marker]) + + def test_page_break_inside_set1_continuations(self): + # The scenario of issue #2313: headers interrupt the continuations. + set1 = set1_lines(list(range(1, 24)), 25, ["+", "+", "+"]) + lines = grid_lines(range(1, 24)) + set1[:2] + PAGE_HEADER + set1[2:] + solver = self.read(lines) + self.assertEqual(self.marker_ids(solver), list(range(1, 24))) + + def test_page_break_between_set1_parent_and_continuation(self): + set1 = set1_lines(list(range(1, 16)), 25, ["+"]) + lines = grid_lines(range(1, 16)) + set1[:1] + PAGE_HEADER + set1[1:] + solver = self.read(lines) + self.assertEqual(self.marker_ids(solver), list(range(1, 16))) + + def test_page_break_between_cord2r_lines(self): + lines = ( + [echo(1, "CORD2R", 1, 0, "0.", "0.", "0.", "0.", "0.", "1.", "+")] + + PAGE_HEADER + + [echo(2, "+", "1.", "0.", "0.")] + + grid_lines([1], start=3) + + [echo(4, "SET1", 1, 1)] + ) + solver = self.read(lines) + self.assertEqual(solver.nRefSys, 1) + np.testing.assert_allclose( + solver.refsystems[0].GetRotMatrix(), np.eye(3), atol=1e-12 + ) + self.assertEqual(self.marker_ids(solver), [1]) + + def test_generated_continuation_markers(self): + # Nastran generates +000001 style markers for blank continuations, + # they must not be read as the grid point 1. + markers = ["+000001", "+000002"] + lines = grid_lines(range(1, 17)) + set1_lines(list(range(1, 17)), 20, markers) + solver = self.read(lines) + self.assertEqual(self.marker_ids(solver), list(range(1, 17))) + + def test_user_named_continuation_markers(self): + lines = grid_lines(range(1, 17)) + set1_lines( + list(range(1, 17)), 20, ["+PB1", "+PB2"] + ) + solver = self.read(lines) + self.assertEqual(self.marker_ids(solver), list(range(1, 17))) + + def test_thru(self): + # SET1 6 29 32 THRU 50 61 THRU 70, the second example of the QRG entry. + ids = [29] + list(range(32, 51)) + list(range(61, 71)) + lines = grid_lines(ids) + [ + echo(40, "SET1", 6, 29, 32, "THRU", 50, 61, "THRU", 70) + ] + solver = self.read(lines, marker="6") + self.assertEqual(self.marker_ids(solver, "6"), sorted(ids)) + + def test_missing_continuation_is_reported(self): + lines = grid_lines(range(1, 9)) + [ + echo(10, "SET1", 1, 1, 2, 3, 4, 5, 6, 7, "+"), + echo(11, "SPC1", 2, 123456, 8), + ] + with self.assertRaisesRegex(Exception, "continuation of SET1 1.*SPC1"): + self.read(lines) + + def test_truncated_file_is_reported(self): + lines = grid_lines(range(1, 9)) + [ + echo(10, "SET1", 1, 1, 2, 3, 4, 5, 6, 7, "+") + ] + with self.assertRaisesRegex(Exception, "end of .*continuation of SET1 1"): + self.read(lines) + + def test_point_missing_from_mesh_is_reported(self): + lines = grid_lines(range(1, 4)) + [echo(5, "SET1", 1, 1, 2, 99)] + with self.assertRaisesRegex(Exception, "Point 99 in the set 1 was not found"): + self.read(lines) + + def test_unpaginated_echo_still_reads(self): + # The tutorial shaped file must parse exactly as before. + lines = grid_lines(range(1, 24)) + set1_lines( + list(range(1, 24)), 25, ["+", "+", "+"] + ) + solver = self.read(lines) + self.assertEqual(solver.nPoint, 23) + self.assertEqual(solver.nMarker, 1) + self.assertEqual(self.marker_ids(solver), list(range(1, 24))) + + +if __name__ == "__main__": + unittest.main()