From a297a71a0de2b05ca282e6298d2dfa98b695a445 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 4 Aug 2026 08:49:02 +0200 Subject: [PATCH] Fix parsing of Nastran small field reals in the modal solver The mesh reader decodes the eight character fields of the GRID and CORD2R entries positionally: nastran_float() rewrote every "-" into "e-" and then dropped a leading "e" by looking at index 0, and __checkBlankField() only recognised a blank field when it was exactly eight spaces long. Both assumptions only hold when a field is left justified and fills all eight columns. The Quick Reference Guide states that fields 2 through 9 do not need to be either right or left justified, and Nastran trims trailing blanks from the lines of the sorted bulk data echo, so: - any negative coordinate that is not left flush raised ValueError, e.g. " -5.2" became " e-5.2"; - a lower case "e" or a "D" exponent was never recognised, so "700.e-2" became "700.ee-2" in any justification; - a GRID entry without the optional CD field ends the echo line after X3, leaving an empty slice that raised ValueError in int(). nastran_float() now normalises the field before interpreting it and inserts the omitted exponent letter at the first sign after the mantissa sign, which is what the reference readers do. It is defined at module level so it can be tested; all twelve call sites use it unchanged. --- SU2_PY/SU2_Nastran/pysu2_nastran.py | 35 +++-- SU2_PY/SU2_Nastran/test_pysu2_nastran.py | 184 +++++++++++++++++++++++ 2 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 SU2_PY/SU2_Nastran/test_pysu2_nastran.py diff --git a/SU2_PY/SU2_Nastran/pysu2_nastran.py b/SU2_PY/SU2_Nastran/pysu2_nastran.py index 9d1b993b054..8a65a0f296d 100644 --- a/SU2_PY/SU2_Nastran/pysu2_nastran.py +++ b/SU2_PY/SU2_Nastran/pysu2_nastran.py @@ -33,6 +33,30 @@ import scipy.linalg as linalg from math import * +# ---------------------------------------------------------------------- +# Bulk data field parsing +# ---------------------------------------------------------------------- + + +def nastran_float(s): + """ + This method converts one small field of a bulk data entry into a float. + + Fields 2 through 9 do not need to be either right or left justified, and + the exponent may be written with an "E", with a "D", or with no letter at + all: "7.0", ".7E1", "0.7+1", ".70+1", "7.E+0" and "70.-1" all mean seven. + """ + + s = s.strip().upper().replace("D", "E") + if "E" not in s: + # The exponent letter is omitted, so the exponent starts at the first + # sign which is not the sign of the mantissa. + signs = [i for i in (s.find("+", 1), s.find("-", 1)) if i > 0] + if signs: + s = s[: min(signs)] + "E" + s[min(signs) :] + return float(s) + + # ---------------------------------------------------------------------- # Config class # ---------------------------------------------------------------------- @@ -426,14 +450,6 @@ def __readNastranMesh(self): This method reads the nastran 3D mesh. """ - def nastran_float(s): - if s.find("E") == -1: - s = s.replace("-", "e-") - s = s.replace("+", "e+") - if s[0] == "e": - s = s[1:] - return float(s) - self.nMarker = 0 self.nPoint = 0 self.nRefSys = 0 @@ -573,7 +589,8 @@ def __checkBlankField(self, string): This method considers that Nastran apply 0 when the reference system is not specified """ - if string == " " * 8: + string = string.strip() + if not string: return int(0) return int(string) diff --git a/SU2_PY/SU2_Nastran/test_pysu2_nastran.py b/SU2_PY/SU2_Nastran/test_pysu2_nastran.py new file mode 100644 index 00000000000..dd4cb0ab4ba --- /dev/null +++ b/SU2_PY/SU2_Nastran/test_pysu2_nastran.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python + +## \file test_pysu2_nastran.py +# \brief Tests for the bulk data parsing of the Nastran structural solver. +# \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, nastran_float + +# All the spellings of the real number seven listed in the Quick Reference +# Guide, "Format of Bulk Data Entries", plus the "D" exponent and the lower +# case forms accepted by the bulk data readers. +SEVEN = [ + "7.0", + ".7E1", + "0.7+1", + ".70+1", + "7.E+0", + "70.-1", + ".7e1", + "700.e-2", + "7.0D0", + ".7d1", + "70.-01", + "7000.-3", +] + + +def justify(value, style): + """Places a value in an eight character field, as Nastran allows.""" + + if style == "left": + return value.ljust(8) + if style == "right": + return value.rjust(8) + return value.center(8) + + +def card(fields, style, prefix=30): + """Builds one line of a small field bulk data echo.""" + + line = fields[0].ljust(8) + for field in fields[1:]: + line += justify(field, style) + return " " * prefix + line.rstrip() + "\n" + + +class TestNastranFloat(unittest.TestCase): + def test_spellings_of_seven(self): + for value in SEVEN: + for style in ("left", "right", "centre"): + field = justify(value, style) + self.assertAlmostEqual(nastran_float(field), 7.0, msg=repr(field)) + + def test_negative_spellings_of_seven(self): + for value in SEVEN: + for style in ("left", "right", "centre"): + field = justify("-" + value, style) + self.assertAlmostEqual(nastran_float(field), -7.0, msg=repr(field)) + + def test_leading_plus_is_not_an_exponent(self): + for style in ("left", "right", "centre"): + self.assertAlmostEqual(nastran_float(justify("+7.0", style)), 7.0) + + def test_omitted_exponent_letter(self): + self.assertAlmostEqual(nastran_float("1.23-5"), 1.23e-5) + self.assertAlmostEqual(nastran_float(" -1.23-5"), -1.23e-5) + self.assertAlmostEqual(nastran_float("-1.23+5"), -1.23e5) + self.assertAlmostEqual(nastran_float(" 1+5"), 1.0e5) + + def test_invalid_field_is_rejected(self): + for field in ("", " ", "abc", "1.2.3"): + with self.assertRaises(ValueError): + nastran_float(field) + + +class TestReadNastranMesh(unittest.TestCase): + """ + Reads the same model written with different, equally valid, spellings. + The parsed geometry must not depend on how the fields were written. + """ + + def mesh(self, style, omit_optional_fields=False): + cd = [] if omit_optional_fields else ["0"] + lines = [ + card( + ["CORD2R", "1", "0", "-1.5", "-2.5", "-3.5", "-1.5", "-2.5", "-0.5"], + style, + ), + card(["+", "0.5", "-2.5", "-3.5"], style), + card(["GRID", "1", "0", "-1.25", "-2.5", "3.75"] + cd, style), + # A blank CP field means the basic coordinate system. + card(["GRID", "2", "", "-1.5-2", "2.5-2", "-3.5+1"] + cd, style), + card(["GRID", "3", "1", "1.0", "2.0", "-3.0"] + cd, style), + card(["SET1", "1", "1", "2", "3"], style), + ] + handle, path = tempfile.mkstemp(suffix=".f06") + with os.fdopen(handle, "w") as mesh_file: + mesh_file.writelines(lines) + self.addCleanup(os.remove, path) + return path + + def read(self, style, omit_optional_fields=False): + solver = Solver.__new__(Solver) + solver.Mesh_file = self.mesh(style, omit_optional_fields) + solver.FSI_marker = "1" + solver.node = [] + solver.markers = {} + solver.refsystems = [] + solver._Solver__readNastranMesh() + return solver + + def coordinates(self, solver): + return np.array([point.GetCoord0().ravel() for point in solver.node]) + + def test_justification_does_not_change_the_model(self): + reference = self.coordinates(self.read("left")) + self.assertEqual(reference.shape, (3, 3)) + for style in ("right", "centre"): + np.testing.assert_allclose(self.coordinates(self.read(style)), reference) + + def test_coordinates_are_read_correctly(self): + for style in ("left", "right", "centre"): + coordinates = self.coordinates(self.read(style)) + # Point 1 is given in the basic system. + np.testing.assert_allclose(coordinates[0], [-1.25, -2.5, 3.75]) + # Point 2 uses the omitted exponent letter. + np.testing.assert_allclose(coordinates[1], [-0.015, 0.025, -35.0]) + # Point 3 is given in the reference system defined by the CORD2R, + # whose origin is (-1.5, -2.5, -3.5) and whose x axis points to + # (0.5, -2.5, -3.5), i.e. the basic x axis. + np.testing.assert_allclose(coordinates[2], [-0.5, -0.5, -6.5]) + + def test_blank_reference_system_field_is_the_basic_system(self): + for style in ("left", "right", "centre"): + solver = self.read(style) + self.assertEqual([point.GetCP() for point in solver.node], [0, 0, 1]) + + def test_optional_trailing_field_may_be_missing(self): + for style in ("left", "right", "centre"): + reference = self.coordinates(self.read(style)) + without_cd = self.coordinates(self.read(style, omit_optional_fields=True)) + np.testing.assert_allclose(without_cd, reference) + + def test_reference_system_is_read_correctly(self): + for style in ("left", "right", "centre"): + solver = self.read(style) + self.assertEqual(len(solver.refsystems), 1) + system = solver.refsystems[0] + self.assertEqual(system.GetCID(), 1) + np.testing.assert_allclose(system.GetOrigin().ravel(), [-1.5, -2.5, -3.5]) + np.testing.assert_allclose(system.GetRotMatrix(), np.eye(3), atol=1e-12) + + +if __name__ == "__main__": + unittest.main()