From 7e0d20f35efccce59ead7f27209bb8b6d7a53eb0 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 4 Aug 2026 09:26:02 +0200 Subject: [PATCH] Fix reading of continuation lines in the Nastran modal solver mesh reader The sorted bulk data echo of an f06 file is paginated: page titles, carriage control lines and column rulers can interrupt an entry at any point. The SET1 and CORD2R readers consumed the next physical line as the continuation, so any page break inside an entry made the reader fail (issue #2313). Continuation markers other than a bare + were also mishandled: Nastran generated markers such as +000001 were silently appended to the marker set as grid point 1. Continuation lines are now located by their marker field, skipping the page furniture, and THRU ranges of the SET1 entry are supported. --- SU2_PY/SU2_Nastran/pysu2_nastran.py | 89 ++++++--- .../test_pysu2_nastran_continuations.py | 182 ++++++++++++++++++ 2 files changed, 245 insertions(+), 26 deletions(-) create mode 100644 SU2_PY/SU2_Nastran/test_pysu2_nastran_continuations.py 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()