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()
diff --git a/TestCases/serial_regression.py b/TestCases/serial_regression.py
index 8adb14300f3..d3588e8af1e 100755
--- a/TestCases/serial_regression.py
+++ b/TestCases/serial_regression.py
@@ -26,6 +26,8 @@
# License along with SU2. If not, see .
from __future__ import print_function, division, absolute_import
+import os
+import subprocess
import sys
from TestCase import TestCase
from TestCase import parse_args
@@ -1257,6 +1259,17 @@ def main():
pass_list = [ test.run_test(args.tsan, args.asan) for test in test_list ]
+ # Nastran bulk data parser unit tests
+ nastran_parser = TestCase('pysu2_nastran')
+ nastran_test = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ 'SU2_PY',
+ 'SU2_Nastran',
+ 'test_pysu2_nastran.py',
+ )
+ pass_list.append(subprocess.call([sys.executable, nastran_test]) == 0)
+ test_list.append(nastran_parser)
+
######################################
### RUN SU2_GEO TESTS ###