From a5a0f428b137a0589c52a7ec0837fca9e425d63a Mon Sep 17 00:00:00 2001 From: bigfooted Date: Sat, 17 Oct 2026 22:22:36 +0200 Subject: [PATCH 01/11] Add SU2_MON history file monitor Co-Authored-By: Claude Sonnet 5.5 --- bin/SU2_MON | 2 + bin/monitor.py | 495 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 497 insertions(+) create mode 100755 bin/SU2_MON create mode 100755 bin/monitor.py diff --git a/bin/SU2_MON b/bin/SU2_MON new file mode 100755 index 000000000000..638e47ad3af2 --- /dev/null +++ b/bin/SU2_MON @@ -0,0 +1,2 @@ +#!/bin/bash +cd "$(dirname "$(readlink -f "$0")")" && python monitor.py "$@" diff --git a/bin/monitor.py b/bin/monitor.py new file mode 100755 index 000000000000..1a3860e67325 --- /dev/null +++ b/bin/monitor.py @@ -0,0 +1,495 @@ +# monitor: displays and updates the residuals of the su2 history file in a window. +import csv +import io +import os.path +import re +import sys + +import matplotlib +import numpy as np +import pandas as pd +from matplotlib import figure +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from PyQt5 import QtCore +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QApplication, QCheckBox, QFileDialog, QFrame, QGridLayout, QGroupBox, \ + QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QSplitter, \ + QTabWidget, QVBoxLayout, QWidget + +matplotlib.rcParams['lines.linewidth'] = 2 +matplotlib.rcParams['axes.labelsize'] = 20 +matplotlib.rcParams['xtick.labelsize'] = 20 +matplotlib.rcParams['ytick.labelsize'] = 20 +matplotlib.rcParams['axes.linewidth'] = 1.6 +for axis in ('xtick', 'ytick'): + matplotlib.rcParams[axis + '.major.size'] = 10.5 + matplotlib.rcParams[axis + '.major.width'] = 1.6 + matplotlib.rcParams[axis + '.minor.size'] = 6 + matplotlib.rcParams[axis + '.minor.width'] = 1.2 +matplotlib.rcParams['legend.fontsize'] = 20 + +RESIDUAL_PATTERN = re.compile('rms|Res') +COEFFICIENT_PATTERN = re.compile('^(CD|CL|CMx|CMy|CMz|CFx|CFy|CFz)$') +MAX_POINTS = 4000 +READ_BLOCK_BYTES = 32 * 1024 * 1024 +UPDATE_INTERVAL_MS = 1000 +LOGO_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logoSU2.png') + + +def cleanName(name): + return name.replace('VARIABLES=', '').replace('"', '').replace(' ', '') + + +# ############################################################### # +# ############################################################### # +# ############################################################### # +class HistoryReader: + """ incremental reader of the columns of an su2 history file matching a name pattern + """ + + def __init__(self, filename, pattern=RESIDUAL_PATTERN): + self.filename = filename + self.pattern = pattern + self.reset() + + def reset(self): + self.offset = 0 + self.cols = None + self.names = [] + self.buf = np.empty((1024, 0)) + self.n = 0 + self.version = 0 + self.readHeader() + + # legacy files start with a TITLE line and have a ZONE line after the header + def readHeader(self): + try: + with open(self.filename, 'rb') as f: + first = f.readline() + legacy = first.startswith(b'TITLE') + header = f.readline() if legacy else first + if legacy: + f.readline() + if not header.endswith(b'\n'): + return + offset = f.tell() + allnames = next(csv.reader([header.decode()])) + except (OSError, UnicodeDecodeError, csv.Error, StopIteration): + return + + self.cols = [i for i, c in enumerate(allnames) if self.pattern.search(cleanName(c))] + self.names = [cleanName(allnames[i]) for i in self.cols] + self.buf = np.empty((1024, len(self.cols))) + self.offset = offset + + # reads the complete lines appended since the last call; returns True if rows were added + def update(self): + try: + size = os.path.getsize(self.filename) + except OSError: + return False + + if self.cols is not None and size < self.offset: + self.reset() + if self.cols is None: + self.readHeader() + if self.cols is None: + return False + + added = False + while size > self.offset: + with open(self.filename, 'rb') as f: + f.seek(self.offset) + chunk = f.read(min(size - self.offset, READ_BLOCK_BYTES)) + end = chunk.rfind(b'\n') + if end < 0: + break + try: + rows = pd.read_csv(io.BytesIO(chunk[:end + 1]), header=None, usecols=self.cols, + dtype=np.float64, on_bad_lines='skip').to_numpy() + except (pd.errors.EmptyDataError, pd.errors.ParserError, ValueError): + break + self.offset += end + 1 + self.append(rows) + added = True + + if added: + self.version += 1 + return added + + def append(self, rows): + needed = self.n + len(rows) + if needed > len(self.buf): + grown = np.empty((max(needed, 2 * len(self.buf)), self.buf.shape[1])) + grown[:self.n] = self.buf[:self.n] + self.buf = grown + self.buf[self.n:needed] = rows + self.n = needed + + +# ############################################################### # +# ############################################################### # +# ############################################################### # +class MatplotlibFigure(QWidget): + """ setup of layout with canvas and toolbar for matplotlib figure + """ + + def __init__(self, pattern, ylabel, title): + super().__init__() + + self.pattern = pattern + self.ylabel = ylabel + self.figure = matplotlib.figure.Figure() + self.canvas = FigureCanvas(self.figure) + self.toolbar = NavigationToolbar(self.canvas, self) + + self.reader = None + self.lines = [] + self.visible = None + self.lastkey = None + + layout = QGridLayout() + self.setLayout(layout) + + groupbox = QGroupBox(title) + layout.addWidget(groupbox) + + mpllayout = QVBoxLayout() + groupbox.setLayout(mpllayout) + mpllayout.addWidget(self.toolbar) + mpllayout.addWidget(self.canvas) + + def setFile(self, filename): + self.reader = HistoryReader(filename, self.pattern) if filename != "" else None + self.visible = None + self.lastkey = None + self.figure.clf() + self.lines = [] + self.canvas.draw_idle() + + def names(self): + return self.reader.names if self.reader is not None else [] + + # plots the residuals from iteration 'start' on; ymin/ymax of None means automatic range + def plot(self, listonoff, normalize, start, ymin, ymax, update=True): + if self.reader is None: + return + if update: + self.reader.update() + + key = (tuple(listonoff), normalize, start, ymin, ymax, self.reader.version) + if key == self.lastkey: + return + self.lastkey = key + + names = self.reader.names + shown = [l for l in range(min(len(listonoff), len(names))) if listonoff[l]] + ax = self.figure.axes[0] if self.figure.axes else self.figure.add_subplot(111) + + rebuild = self.visible != [names[l] for l in shown] + if rebuild: + self.visible = [names[l] for l in shown] + ax.clear() + ax.set_xlabel('iterations') + ax.set_ylabel(self.ylabel) + self.lines = [ax.plot([], [], label=names[l])[0] for l in shown] + + n = self.reader.n + first = min(max(start, 1), max(n, 1)) + step = max(1, -(-(n - first) // MAX_POINTS)) + x = np.arange(first, n, step) + for line, l in zip(self.lines, shown): + y = self.reader.buf[first:n:step, l] + if normalize and n > 1 and abs(self.reader.buf[1, l]) > 1e-6: + y = 1 + y - self.reader.buf[1, l] + line.set_data(x, y) + + ax.set_autoscaley_on(True) + ax.relim() + ax.autoscale_view() + if ymin is not None or ymax is not None: + low, high = ax.get_ylim() + low = low if ymin is None else ymin + high = high if ymax is None else ymax + if low < high: + ax.set_ylim(low, high) + + if rebuild: + if self.lines: + ax.legend(bbox_to_anchor=(1.01, 1), loc='upper left', prop={'size': 6, 'weight': 'bold'}, + borderaxespad=None) + self.figure.tight_layout() + + self.canvas.draw_idle() + + +# ############################################################### # +# ############################################################### # +# ############################################################### # +class MainApp(QMainWindow): + """ Create the main window + """ + def __init__(self): + + super().__init__() + self.title = 'SU2 convergence monitor' + + # start at top left corner of the screen + self.left = 0 + self.top = 0 + + # set initial window height and width + self.width = 1280 + self.height = 780 + + # set the styles of fonts etc. + self.setStyleSheet('font-size: 12pt') + + # user interface + self.makeUI() + + def makeUI(self): + # set window geometry + self.setWindowTitle(self.title) + self.setWindowIcon(QIcon(LOGO_FILE)) + self.setGeometry(self.left, self.top, self.width, self.height) + + # generate the tab layout + self.table_widget = MyTableWidget() + self.setCentralWidget(self.table_widget) + + self.show() + + +# ############################################################### # +# ############################################################### # +# ############################################################### # +class PlotTab(QWidget): + """ one tab with the buttons, the data selection and the plot of a group of history columns + """ + + def __init__(self, owner, pattern, ylabel, title): + super().__init__() + self.owner = owner + self.pattern = pattern + self.ylabel = ylabel + self.normalize = False + self.chkbx = [] + self.chkbx_names = [] + self.make_tab(title) + + def setFile(self, filename, label, tooltip): + self.show_filename.setText(label) + self.show_filename.setToolTip(tooltip) + self.figure.setFile(filename) + self.update_chkbx(self.figure.names()) + self.plot_data() + + def setRunning(self, running): + self.btn_plot_data.setText('stop' if running else 'start') + self.btn_plot_data.setToolTip('Click to stop real time data gathering' if running + else 'Click to start real time data gathering') + + # ################################### # + # checkboxes for all the individual residual lines of the variables + def update_chkbx(self, names): + while self.chk_layout.count(): + widget = self.chk_layout.takeAt(0).widget() + widget.setParent(None) + widget.deleteLater() + + self.chkbx = [] + for name in names: + box = QCheckBox(name) + box.setChecked(True) + box.toggled.connect(self.changedata) + self.chk_layout.addWidget(box) + self.chkbx.append(box) + self.chkbx_names = list(names) + + # ################################### # + def update_normalize(self, state): + self.normalize = (state == QtCore.Qt.Checked) + self.changedata() + + # ################################### # + def make_tab(self, title): + + self.figure = MatplotlibFigure(self.pattern, self.ylabel, title) + + '''file dialog section''' + self.btn_openfile = QPushButton("Select File") + self.btn_openfile.clicked.connect(self.owner.getfile) + self.show_filename = QLabel("No file selected") + self.btn_openfile.setToolTip('click to select the file containing the residual data') + + '''button section''' + # temporarily stop the realtime update + self.btn_plot_data = QPushButton('stop') + self.btn_plot_data.setToolTip('Click to stop real time data gathering') + self.btn_plot_data.clicked.connect(self.owner.timer_startstop) + + self.btn_normalize = QCheckBox('Normalize') + self.btn_normalize.setToolTip('Normalize all residuals with the value at iteration 1 ') + self.btn_normalize.stateChanged.connect(self.update_normalize) + + '''plot range section''' + self.edit_start = QLineEdit() + self.edit_start.setPlaceholderText('1') + self.edit_start.setToolTip('First iteration shown in the plot') + self.edit_ymin = QLineEdit() + self.edit_ymin.setPlaceholderText('auto') + self.edit_ymin.setToolTip('Lower limit of the y-axis, empty for automatic range') + self.edit_ymax = QLineEdit() + self.edit_ymax.setPlaceholderText('auto') + self.edit_ymax.setToolTip('Upper limit of the y-axis, empty for automatic range') + + range_layout = QGridLayout() + for row, (label, edit) in enumerate([('start it.', self.edit_start), + ('y min', self.edit_ymin), + ('y max', self.edit_ymax)]): + edit.editingFinished.connect(self.changedata) + range_layout.addWidget(QLabel(label), row, 0) + range_layout.addWidget(edit, row, 1) + + self.chk_layout = QVBoxLayout() + + # now we add all the buttons in a vertical list + self.btn_layout = QVBoxLayout() + self.btn_layout.addWidget(self.btn_openfile) + self.btn_layout.addWidget(self.show_filename) + self.btn_layout.addWidget(self.btn_plot_data) + self.btn_layout.addWidget(self.btn_normalize) + self.btn_layout.addLayout(range_layout) + self.btn_layout.addLayout(self.chk_layout) + + # this adds stretch, so the buttons are always top-aligned (in a vbox) + self.btn_layout.addStretch() + + '''left-side layout''' + left = QFrame() + left.setLayout(self.btn_layout) + + # combine the buttons and the canvas in a splitter layout + splitter1 = QSplitter(QtCore.Qt.Horizontal) + splitter1.addWidget(left) + splitter1.addWidget(self.figure) + splitter1.setSizes([75, 700]) + + '''master layout of tab''' + self.tab = QWidget() + self.tab.layout = QHBoxLayout() + self.tab.layout.addWidget(splitter1) + self.tab.setLayout(self.tab.layout) + + self.layout = QVBoxLayout(self) + self.layout.addWidget(self.tab) + + # ################################### # + @staticmethod + def parse(edit, convert): + try: + return convert(edit.text().strip()) + except ValueError: + return None + + def draw(self, update): + start = self.parse(self.edit_start, lambda t: int(float(t))) + self.figure.plot([c.isChecked() for c in self.chkbx], self.normalize, + 1 if start is None else start, + self.parse(self.edit_ymin, float), self.parse(self.edit_ymax, float), + update=update) + + # ################################### # + def plot_data(self): + if self.figure.reader is not None: + self.figure.reader.update() + if self.figure.names() != self.chkbx_names: + self.update_chkbx(self.figure.names()) + self.draw(update=False) + + # ################################### # + # redraw from the cached data after a change of the plot settings + def changedata(self): + self.draw(update=False) + + +# ############################################################### # +# ############################################################### # +# ############################################################### # +class MyTableWidget(QWidget): + """ holds the tabs and the shared file name and update timer + """ + + def __init__(self): + + super().__init__() + self.layout = QVBoxLayout(self) + + # initialization of the filename containing the history data + self.filename = "" + + self.tabs = QTabWidget() + self.tabs.resize(600, 600) + + self.plottabs = [PlotTab(self, RESIDUAL_PATTERN, 'residuals', 'Convergence'), + PlotTab(self, COEFFICIENT_PATTERN, 'coefficients', 'Coefficients')] + self.tabs.addTab(self.plottabs[0], 'Convergence plot') + self.tabs.addTab(self.plottabs[1], 'Coefficients plot') + + self.layout.addWidget(self.tabs) + self.setLayout(self.layout) + + # setup a timer for plot update every 'UPDATE_INTERVAL_MS' ms + self.timer = QtCore.QTimer() + self.timer.setInterval(UPDATE_INTERVAL_MS) + self.timer.timeout.connect(self.plot_data) + self.timer.start() + + # ################################### # + # file dialog for monitor input + def getfile(self): + + fname = QFileDialog.getOpenFileName(self, 'Open file', 'history.csv', filter='csv files (*.csv)\n' + 'dat files (*.dat)\n' + 'all files (*)') + if fname[0] == "": + return + + self.filename = fname[0] + fn = os.path.basename(self.filename) + fb = os.path.basename(os.path.dirname(self.filename)) + for tab in self.plottabs: + tab.setFile(self.filename, os.path.join(fb, fn), self.filename) + + # ################################### # + # start or stop the realtime update of all tabs + def timer_startstop(self): + if self.timer.isActive(): + self.timer.stop() + else: + self.timer.start() + for tab in self.plottabs: + tab.setRunning(self.timer.isActive()) + + # ################################### # + def plot_data(self): + for tab in self.plottabs: + tab.plot_data() + + +# ############################################################### # +# ############################################################### # +# ############################################################### # +if __name__ == '__main__': + + def run_app(): + app = QApplication(sys.argv) + app.setDesktopFileName('su2-monitor') + app.setWindowIcon(QIcon(LOGO_FILE)) + mainWin = MainApp() + mainWin.show() + app.exec_() + + run_app() From 513bdfdb752cf5db26e883cc36e66d971190a834 Mon Sep 17 00:00:00 2001 From: Nijso Date: Sat, 17 Oct 2026 22:27:07 +0200 Subject: [PATCH 02/11] Potential fix for pull request finding 'CodeQL / Unused import' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- bin/monitor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/monitor.py b/bin/monitor.py index 1a3860e67325..6d054298903a 100755 --- a/bin/monitor.py +++ b/bin/monitor.py @@ -8,7 +8,6 @@ import matplotlib import numpy as np import pandas as pd -from matplotlib import figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from PyQt5 import QtCore From 912866a0d9d6cd1d269be54c3cd0362daa0a8ee7 Mon Sep 17 00:00:00 2001 From: bigfooted Date: Sat, 17 Oct 2026 22:30:36 +0200 Subject: [PATCH 03/11] pre-commit --- .pre-commit-config.yaml | 2 +- SU2_PY/FSI_tools/FSIInterface.py | 12 +- SU2_PY/SU2/eval/functions.py | 1 - SU2_PY/SU2/io/filelock.py | 1 + SU2_PY/SU2/io/redirect.py | 1 + SU2_PY/SU2/io/state.py | 1 - SU2_PY/SU2/io/tools.py | 10 +- SU2_PY/SU2/opt/scipy_tools.py | 1 - SU2_PY/SU2/run/deform.py | 1 - SU2_PY/SU2/run/direct.py | 6 +- SU2_PY/SU2/run/projection.py | 1 - SU2_PY/SU2/util/bunch.py | 48 +++---- SU2_PY/SU2/util/filter_adjoint.py | 2 +- SU2_PY/SU2/util/ordered_bunch.py | 48 +++---- SU2_PY/SU2/util/ordered_dict.py | 6 +- SU2_PY/aeroacoustics.py | 1 - SU2_PY/tests/test_aeroacoustics.py | 1 - SU2_PY/topology_optimization.py | 1 + bin/monitor.py | 207 ++++++++++++++++++----------- meson.py | 1 - 20 files changed, 198 insertions(+), 154 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1e55bb83a103..a9e9f8d307ce 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: types_or: [c++, c] # black repo for python formatting - repo: https://github.com/ambv/black - rev: 22.6.0 + rev: 26.10.1 hooks: - id: black exclude: | diff --git a/SU2_PY/FSI_tools/FSIInterface.py b/SU2_PY/FSI_tools/FSIInterface.py index 01f2fa2e14bc..bf5eabe16dc2 100644 --- a/SU2_PY/FSI_tools/FSIInterface.py +++ b/SU2_PY/FSI_tools/FSIInterface.py @@ -478,9 +478,9 @@ def connect(self, FSI_config, FluidSolver, SolidSolver): ) del sendBuffPhysical else: - self.fluidPhysicalInterfaceNodesDistribution[ - 0 - ] = self.nFluidInterfacePhysicalNodes + self.fluidPhysicalInterfaceNodesDistribution[0] = ( + self.nFluidInterfacePhysicalNodes + ) # Same thing for the solid part self.solidPhysicalInterfaceNodesDistribution = np.zeros(MPIsize, dtype=int) @@ -491,9 +491,9 @@ def connect(self, FSI_config, FluidSolver, SolidSolver): ) del sendBuffPhysical else: - self.solidPhysicalInterfaceNodesDistribution[ - 0 - ] = self.nSolidInterfacePhysicalNodes + self.solidPhysicalInterfaceNodesDistribution[0] = ( + self.nSolidInterfacePhysicalNodes + ) # --- Calculate and store the global indexing of interface physical nodes on each processor and allgather the information --- if self.have_MPI: diff --git a/SU2_PY/SU2/eval/functions.py b/SU2_PY/SU2/eval/functions.py index fd6c1103a519..7f6bf4ee41cf 100644 --- a/SU2_PY/SU2/eval/functions.py +++ b/SU2_PY/SU2/eval/functions.py @@ -35,7 +35,6 @@ from .. import util as su2util from ..io import redirect_folder, redirect_output - # ---------------------------------------------------------------------- # Main Function Interface # ---------------------------------------------------------------------- diff --git a/SU2_PY/SU2/io/filelock.py b/SU2_PY/SU2/io/filelock.py index 45190e1711a0..e1101031e78a 100644 --- a/SU2_PY/SU2/io/filelock.py +++ b/SU2_PY/SU2/io/filelock.py @@ -28,6 +28,7 @@ import os, time, errno from random import random + # ------------------------------------------------------------------- # File Lock Class # ------------------------------------------------------------------- diff --git a/SU2_PY/SU2/io/redirect.py b/SU2_PY/SU2/io/redirect.py index 161d4cc31f0c..ff1b7a5d4ea1 100644 --- a/SU2_PY/SU2/io/redirect.py +++ b/SU2_PY/SU2/io/redirect.py @@ -32,6 +32,7 @@ import os, sys, shutil, copy, glob from .tools import add_suffix, make_link, expand_part + # ------------------------------------------------------------------- # Output Redirection # ------------------------------------------------------------------- diff --git a/SU2_PY/SU2/io/state.py b/SU2_PY/SU2/io/state.py index f67a9004b81d..d904258e8e60 100644 --- a/SU2_PY/SU2/io/state.py +++ b/SU2_PY/SU2/io/state.py @@ -44,7 +44,6 @@ from ..util import bunch from ..util import ordered_bunch - # ---------------------------------------------------------------------- # State Factory # ---------------------------------------------------------------------- diff --git a/SU2_PY/SU2/io/tools.py b/SU2_PY/SU2/io/tools.py index aaeccf9433fc..237ac021002f 100755 --- a/SU2_PY/SU2/io/tools.py +++ b/SU2_PY/SU2/io/tools.py @@ -302,6 +302,7 @@ def getTurboPerfIndex(nZones=1): "EFFICIENCY": "CL/CD", } + # ------------------------------------------------------------------- # Include per-surface output from History File # ------------------------------------------------------------------- @@ -361,9 +362,9 @@ def read_aerodynamics( historyOutFields[this_objfun]["TYPE"] == "COEFFICIENT" or historyOutFields[this_objfun]["TYPE"] == "D_COEFFICIENT" ): - Func_Values[ - this_objfun + "[" + str(iZone) + "]" - ] = history_data[this_objfun + "[" + str(iZone) + "]"] + Func_Values[this_objfun + "[" + str(iZone) + "]"] = ( + history_data[this_objfun + "[" + str(iZone) + "]"] + ) if "TIME_MARCHING" in special_cases: # for unsteady cases, average time-accurate objective function values @@ -626,6 +627,7 @@ def get_dvMap(): #: def get_dvMap() + # ------------------------------------------------------------------- # Get Design Variable Kind Name from ID # ------------------------------------------------------------------- @@ -640,6 +642,7 @@ def get_dvKind(kindID): # def get_dvKind() + # ------------------------------------------------------------------- # Get Design Variable Kind ID from Name # ------------------------------------------------------------------- @@ -996,6 +999,7 @@ def get_specialCases(config): #: def get_specialCases() + # ------------------------------------------------------------------- # Check Fluid Structure Interaction # ------------------------------------------------------------------- diff --git a/SU2_PY/SU2/opt/scipy_tools.py b/SU2_PY/SU2/opt/scipy_tools.py index e8551b67ac08..1029bc6f7df4 100644 --- a/SU2_PY/SU2/opt/scipy_tools.py +++ b/SU2_PY/SU2/opt/scipy_tools.py @@ -34,7 +34,6 @@ from .. import eval as su2eval from numpy import array, zeros - # ------------------------------------------------------------------- # Scipy SLSQP # ------------------------------------------------------------------- diff --git a/SU2_PY/SU2/run/deform.py b/SU2_PY/SU2/run/deform.py index 8ead56f11509..7feb9feaef91 100644 --- a/SU2_PY/SU2/run/deform.py +++ b/SU2_PY/SU2/run/deform.py @@ -34,7 +34,6 @@ from .. import io as su2io from .interface import DEF as SU2_DEF - # ---------------------------------------------------------------------- # Mesh Deformation # ---------------------------------------------------------------------- diff --git a/SU2_PY/SU2/run/direct.py b/SU2_PY/SU2/run/direct.py index 60825cae7a63..f6f91089e513 100644 --- a/SU2_PY/SU2/run/direct.py +++ b/SU2_PY/SU2/run/direct.py @@ -93,9 +93,9 @@ def direct(config): # check for 'RESTART_ITER' is to avoid forced restart situation in "compute_polar.py"... if konfig.get("RESTART_SOL", "NO") == "YES" and konfig.get("RESTART_ITER", 1) != 1: if konfig.get("CONFIG_LIST", []) != []: - konfig[ - "CONV_FILENAME" - ] = "config_CFD" # master cfg is always config_CFD. Hardcoded names are prob nt ideal. + konfig["CONV_FILENAME"] = ( + "config_CFD" # master cfg is always config_CFD. Hardcoded names are prob nt ideal. + ) restart_iter = "_" + str(konfig["RESTART_ITER"]).zfill(5) history_filename = konfig["CONV_FILENAME"] + restart_iter + plot_extension else: diff --git a/SU2_PY/SU2/run/projection.py b/SU2_PY/SU2/run/projection.py index 80741ba70759..4811c947a6a1 100644 --- a/SU2_PY/SU2/run/projection.py +++ b/SU2_PY/SU2/run/projection.py @@ -35,7 +35,6 @@ from .. import util as su2util from .interface import DOT as SU2_DOT - # ---------------------------------------------------------------------- # Gradient Projection # ---------------------------------------------------------------------- diff --git a/SU2_PY/SU2/util/bunch.py b/SU2_PY/SU2/util/bunch.py index 156e681e7f78..44afe34f8eee 100644 --- a/SU2_PY/SU2/util/bunch.py +++ b/SU2_PY/SU2/util/bunch.py @@ -1,29 +1,29 @@ #!/usr/bin/env python -""" Bunch is a subclass of dict with attribute-style access. - - >>> b = Bunch() - >>> b.hello = 'world' - >>> b.hello - 'world' - >>> b['hello'] += "!" - >>> b.hello - 'world!' - >>> b.foo = Bunch(lol=True) - >>> b.foo.lol - True - >>> b.foo is b['foo'] - True - - It is safe to import * from this module: - - __all__ = ('Bunch', 'bunchify','unbunchify') - - un/bunchify provide dictionary conversion; Bunches can also be - converted via Bunch.to/fromDict(). - - original source: - https://pypi.python.org/pypi/bunch +"""Bunch is a subclass of dict with attribute-style access. + +>>> b = Bunch() +>>> b.hello = 'world' +>>> b.hello +'world' +>>> b['hello'] += "!" +>>> b.hello +'world!' +>>> b.foo = Bunch(lol=True) +>>> b.foo.lol +True +>>> b.foo is b['foo'] +True + +It is safe to import * from this module: + + __all__ = ('Bunch', 'bunchify','unbunchify') + +un/bunchify provide dictionary conversion; Bunches can also be +converted via Bunch.to/fromDict(). + +original source: +https://pypi.python.org/pypi/bunch """ diff --git a/SU2_PY/SU2/util/filter_adjoint.py b/SU2_PY/SU2/util/filter_adjoint.py index d3a541bbc6bf..c290812786bf 100644 --- a/SU2_PY/SU2/util/filter_adjoint.py +++ b/SU2_PY/SU2/util/filter_adjoint.py @@ -79,7 +79,7 @@ def main(): metavar="CHORD", ) - (options, args) = parser.parse_args() + options, args = parser.parse_args() options.chord_length = float(options.chord_length) # run filter diff --git a/SU2_PY/SU2/util/ordered_bunch.py b/SU2_PY/SU2/util/ordered_bunch.py index 7aa0f33427de..df8c4bf26172 100644 --- a/SU2_PY/SU2/util/ordered_bunch.py +++ b/SU2_PY/SU2/util/ordered_bunch.py @@ -1,29 +1,29 @@ #!/usr/bin/env python -""" OrderedBunch is a subclass of OrderedDict with attribute-style access. - - >>> b = OrderedBunch() - >>> b.hello = 'world' - >>> b.hello - 'world' - >>> b['hello'] += "!" - >>> b.hello - 'world!' - >>> b.foo = OrderedBunch(lol=True) - >>> b.foo.lol - True - >>> b.foo is b['foo'] - True - - It is safe to import * from this module: - - __all__ = ('OrderedBunch', 'ordered_bunchify','ordered_unbunchify') - - ordered_un/bunchify provide dictionary conversion; Bunches can also be - converted via OrderedBunch.to/fromOrderedDict(). - - original source: - https://pypi.python.org/pypi/bunch +"""OrderedBunch is a subclass of OrderedDict with attribute-style access. + +>>> b = OrderedBunch() +>>> b.hello = 'world' +>>> b.hello +'world' +>>> b['hello'] += "!" +>>> b.hello +'world!' +>>> b.foo = OrderedBunch(lol=True) +>>> b.foo.lol +True +>>> b.foo is b['foo'] +True + +It is safe to import * from this module: + + __all__ = ('OrderedBunch', 'ordered_bunchify','ordered_unbunchify') + +ordered_un/bunchify provide dictionary conversion; Bunches can also be +converted via OrderedBunch.to/fromOrderedDict(). + +original source: +https://pypi.python.org/pypi/bunch """ from .ordered_dict import OrderedDict diff --git a/SU2_PY/SU2/util/ordered_dict.py b/SU2_PY/SU2/util/ordered_dict.py index 776aa1ca55fc..b4fbe21066f7 100644 --- a/SU2_PY/SU2/util/ordered_dict.py +++ b/SU2_PY/SU2/util/ordered_dict.py @@ -1,6 +1,6 @@ -""" Backport of OrderedDict() class that runs on Python 2.4, 2.5, 2.6, 2.7 and pypy. - Passes Python2.7's test suite and incorporates all the latest updates. - {{{ http://code.activestate.com/recipes/576693/ (r9) +"""Backport of OrderedDict() class that runs on Python 2.4, 2.5, 2.6, 2.7 and pypy. +Passes Python2.7's test suite and incorporates all the latest updates. +{{{ http://code.activestate.com/recipes/576693/ (r9) """ try: diff --git a/SU2_PY/aeroacoustics.py b/SU2_PY/aeroacoustics.py index 5243a6668f6e..ae2aca0f4bba 100755 --- a/SU2_PY/aeroacoustics.py +++ b/SU2_PY/aeroacoustics.py @@ -35,7 +35,6 @@ import numpy as np - WINDOWS = { "hann": np.hanning, "hamming": np.hamming, diff --git a/SU2_PY/tests/test_aeroacoustics.py b/SU2_PY/tests/test_aeroacoustics.py index 31cc242aba7c..573141fcf496 100644 --- a/SU2_PY/tests/test_aeroacoustics.py +++ b/SU2_PY/tests/test_aeroacoustics.py @@ -10,7 +10,6 @@ import numpy as np - MODULE_PATH = Path(__file__).parents[1] / "aeroacoustics.py" SPEC = importlib.util.spec_from_file_location("aeroacoustics", MODULE_PATH) AEROACOUSTICS = importlib.util.module_from_spec(SPEC) diff --git a/SU2_PY/topology_optimization.py b/SU2_PY/topology_optimization.py index 068e006cb878..b8d57c1cebd5 100755 --- a/SU2_PY/topology_optimization.py +++ b/SU2_PY/topology_optimization.py @@ -225,6 +225,7 @@ def con_der(self, x): ####### Helpers ####### + # updates the parameters in the config files def update_settings(fnames, params): for fname in fnames: diff --git a/bin/monitor.py b/bin/monitor.py index 1a3860e67325..d91579ef6451 100755 --- a/bin/monitor.py +++ b/bin/monitor.py @@ -13,40 +13,53 @@ from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from PyQt5 import QtCore from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QApplication, QCheckBox, QFileDialog, QFrame, QGridLayout, QGroupBox, \ - QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QSplitter, \ - QTabWidget, QVBoxLayout, QWidget - -matplotlib.rcParams['lines.linewidth'] = 2 -matplotlib.rcParams['axes.labelsize'] = 20 -matplotlib.rcParams['xtick.labelsize'] = 20 -matplotlib.rcParams['ytick.labelsize'] = 20 -matplotlib.rcParams['axes.linewidth'] = 1.6 -for axis in ('xtick', 'ytick'): - matplotlib.rcParams[axis + '.major.size'] = 10.5 - matplotlib.rcParams[axis + '.major.width'] = 1.6 - matplotlib.rcParams[axis + '.minor.size'] = 6 - matplotlib.rcParams[axis + '.minor.width'] = 1.2 -matplotlib.rcParams['legend.fontsize'] = 20 - -RESIDUAL_PATTERN = re.compile('rms|Res') -COEFFICIENT_PATTERN = re.compile('^(CD|CL|CMx|CMy|CMz|CFx|CFy|CFz)$') +from PyQt5.QtWidgets import ( + QApplication, + QCheckBox, + QFileDialog, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QPushButton, + QSplitter, + QTabWidget, + QVBoxLayout, + QWidget, +) + +matplotlib.rcParams["lines.linewidth"] = 2 +matplotlib.rcParams["axes.labelsize"] = 20 +matplotlib.rcParams["xtick.labelsize"] = 20 +matplotlib.rcParams["ytick.labelsize"] = 20 +matplotlib.rcParams["axes.linewidth"] = 1.6 +for axis in ("xtick", "ytick"): + matplotlib.rcParams[axis + ".major.size"] = 10.5 + matplotlib.rcParams[axis + ".major.width"] = 1.6 + matplotlib.rcParams[axis + ".minor.size"] = 6 + matplotlib.rcParams[axis + ".minor.width"] = 1.2 +matplotlib.rcParams["legend.fontsize"] = 20 + +RESIDUAL_PATTERN = re.compile("rms|Res") +COEFFICIENT_PATTERN = re.compile("^(CD|CL|CMx|CMy|CMz|CFx|CFy|CFz)$") MAX_POINTS = 4000 READ_BLOCK_BYTES = 32 * 1024 * 1024 UPDATE_INTERVAL_MS = 1000 -LOGO_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logoSU2.png') +LOGO_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logoSU2.png") def cleanName(name): - return name.replace('VARIABLES=', '').replace('"', '').replace(' ', '') + return name.replace("VARIABLES=", "").replace('"', "").replace(" ", "") # ############################################################### # # ############################################################### # # ############################################################### # class HistoryReader: - """ incremental reader of the columns of an su2 history file matching a name pattern - """ + """incremental reader of the columns of an su2 history file matching a name pattern""" def __init__(self, filename, pattern=RESIDUAL_PATTERN): self.filename = filename @@ -65,20 +78,22 @@ def reset(self): # legacy files start with a TITLE line and have a ZONE line after the header def readHeader(self): try: - with open(self.filename, 'rb') as f: + with open(self.filename, "rb") as f: first = f.readline() - legacy = first.startswith(b'TITLE') + legacy = first.startswith(b"TITLE") header = f.readline() if legacy else first if legacy: f.readline() - if not header.endswith(b'\n'): + if not header.endswith(b"\n"): return offset = f.tell() allnames = next(csv.reader([header.decode()])) except (OSError, UnicodeDecodeError, csv.Error, StopIteration): return - self.cols = [i for i, c in enumerate(allnames) if self.pattern.search(cleanName(c))] + self.cols = [ + i for i, c in enumerate(allnames) if self.pattern.search(cleanName(c)) + ] self.names = [cleanName(allnames[i]) for i in self.cols] self.buf = np.empty((1024, len(self.cols))) self.offset = offset @@ -99,15 +114,20 @@ def update(self): added = False while size > self.offset: - with open(self.filename, 'rb') as f: + with open(self.filename, "rb") as f: f.seek(self.offset) chunk = f.read(min(size - self.offset, READ_BLOCK_BYTES)) - end = chunk.rfind(b'\n') + end = chunk.rfind(b"\n") if end < 0: break try: - rows = pd.read_csv(io.BytesIO(chunk[:end + 1]), header=None, usecols=self.cols, - dtype=np.float64, on_bad_lines='skip').to_numpy() + rows = pd.read_csv( + io.BytesIO(chunk[: end + 1]), + header=None, + usecols=self.cols, + dtype=np.float64, + on_bad_lines="skip", + ).to_numpy() except (pd.errors.EmptyDataError, pd.errors.ParserError, ValueError): break self.offset += end + 1 @@ -122,9 +142,9 @@ def append(self, rows): needed = self.n + len(rows) if needed > len(self.buf): grown = np.empty((max(needed, 2 * len(self.buf)), self.buf.shape[1])) - grown[:self.n] = self.buf[:self.n] + grown[: self.n] = self.buf[: self.n] self.buf = grown - self.buf[self.n:needed] = rows + self.buf[self.n : needed] = rows self.n = needed @@ -132,8 +152,7 @@ def append(self, rows): # ############################################################### # # ############################################################### # class MatplotlibFigure(QWidget): - """ setup of layout with canvas and toolbar for matplotlib figure - """ + """setup of layout with canvas and toolbar for matplotlib figure""" def __init__(self, pattern, ylabel, title): super().__init__() @@ -191,7 +210,7 @@ def plot(self, listonoff, normalize, start, ymin, ymax, update=True): if rebuild: self.visible = [names[l] for l in shown] ax.clear() - ax.set_xlabel('iterations') + ax.set_xlabel("iterations") ax.set_ylabel(self.ylabel) self.lines = [ax.plot([], [], label=names[l])[0] for l in shown] @@ -217,8 +236,12 @@ def plot(self, listonoff, normalize, start, ymin, ymax, update=True): if rebuild: if self.lines: - ax.legend(bbox_to_anchor=(1.01, 1), loc='upper left', prop={'size': 6, 'weight': 'bold'}, - borderaxespad=None) + ax.legend( + bbox_to_anchor=(1.01, 1), + loc="upper left", + prop={"size": 6, "weight": "bold"}, + borderaxespad=None, + ) self.figure.tight_layout() self.canvas.draw_idle() @@ -228,12 +251,12 @@ def plot(self, listonoff, normalize, start, ymin, ymax, update=True): # ############################################################### # # ############################################################### # class MainApp(QMainWindow): - """ Create the main window - """ + """Create the main window""" + def __init__(self): super().__init__() - self.title = 'SU2 convergence monitor' + self.title = "SU2 convergence monitor" # start at top left corner of the screen self.left = 0 @@ -244,7 +267,7 @@ def __init__(self): self.height = 780 # set the styles of fonts etc. - self.setStyleSheet('font-size: 12pt') + self.setStyleSheet("font-size: 12pt") # user interface self.makeUI() @@ -266,8 +289,7 @@ def makeUI(self): # ############################################################### # # ############################################################### # class PlotTab(QWidget): - """ one tab with the buttons, the data selection and the plot of a group of history columns - """ + """one tab with the buttons, the data selection and the plot of a group of history columns""" def __init__(self, owner, pattern, ylabel, title): super().__init__() @@ -287,9 +309,12 @@ def setFile(self, filename, label, tooltip): self.plot_data() def setRunning(self, running): - self.btn_plot_data.setText('stop' if running else 'start') - self.btn_plot_data.setToolTip('Click to stop real time data gathering' if running - else 'Click to start real time data gathering') + self.btn_plot_data.setText("stop" if running else "start") + self.btn_plot_data.setToolTip( + "Click to stop real time data gathering" + if running + else "Click to start real time data gathering" + ) # ################################### # # checkboxes for all the individual residual lines of the variables @@ -310,7 +335,7 @@ def update_chkbx(self, names): # ################################### # def update_normalize(self, state): - self.normalize = (state == QtCore.Qt.Checked) + self.normalize = state == QtCore.Qt.Checked self.changedata() # ################################### # @@ -318,37 +343,49 @@ def make_tab(self, title): self.figure = MatplotlibFigure(self.pattern, self.ylabel, title) - '''file dialog section''' + """file dialog section""" self.btn_openfile = QPushButton("Select File") self.btn_openfile.clicked.connect(self.owner.getfile) self.show_filename = QLabel("No file selected") - self.btn_openfile.setToolTip('click to select the file containing the residual data') + self.btn_openfile.setToolTip( + "click to select the file containing the residual data" + ) - '''button section''' + """button section""" # temporarily stop the realtime update - self.btn_plot_data = QPushButton('stop') - self.btn_plot_data.setToolTip('Click to stop real time data gathering') + self.btn_plot_data = QPushButton("stop") + self.btn_plot_data.setToolTip("Click to stop real time data gathering") self.btn_plot_data.clicked.connect(self.owner.timer_startstop) - self.btn_normalize = QCheckBox('Normalize') - self.btn_normalize.setToolTip('Normalize all residuals with the value at iteration 1 ') + self.btn_normalize = QCheckBox("Normalize") + self.btn_normalize.setToolTip( + "Normalize all residuals with the value at iteration 1 " + ) self.btn_normalize.stateChanged.connect(self.update_normalize) - '''plot range section''' + """plot range section""" self.edit_start = QLineEdit() - self.edit_start.setPlaceholderText('1') - self.edit_start.setToolTip('First iteration shown in the plot') + self.edit_start.setPlaceholderText("1") + self.edit_start.setToolTip("First iteration shown in the plot") self.edit_ymin = QLineEdit() - self.edit_ymin.setPlaceholderText('auto') - self.edit_ymin.setToolTip('Lower limit of the y-axis, empty for automatic range') + self.edit_ymin.setPlaceholderText("auto") + self.edit_ymin.setToolTip( + "Lower limit of the y-axis, empty for automatic range" + ) self.edit_ymax = QLineEdit() - self.edit_ymax.setPlaceholderText('auto') - self.edit_ymax.setToolTip('Upper limit of the y-axis, empty for automatic range') + self.edit_ymax.setPlaceholderText("auto") + self.edit_ymax.setToolTip( + "Upper limit of the y-axis, empty for automatic range" + ) range_layout = QGridLayout() - for row, (label, edit) in enumerate([('start it.', self.edit_start), - ('y min', self.edit_ymin), - ('y max', self.edit_ymax)]): + for row, (label, edit) in enumerate( + [ + ("start it.", self.edit_start), + ("y min", self.edit_ymin), + ("y max", self.edit_ymax), + ] + ): edit.editingFinished.connect(self.changedata) range_layout.addWidget(QLabel(label), row, 0) range_layout.addWidget(edit, row, 1) @@ -367,7 +404,7 @@ def make_tab(self, title): # this adds stretch, so the buttons are always top-aligned (in a vbox) self.btn_layout.addStretch() - '''left-side layout''' + """left-side layout""" left = QFrame() left.setLayout(self.btn_layout) @@ -377,7 +414,7 @@ def make_tab(self, title): splitter1.addWidget(self.figure) splitter1.setSizes([75, 700]) - '''master layout of tab''' + """master layout of tab""" self.tab = QWidget() self.tab.layout = QHBoxLayout() self.tab.layout.addWidget(splitter1) @@ -396,10 +433,14 @@ def parse(edit, convert): def draw(self, update): start = self.parse(self.edit_start, lambda t: int(float(t))) - self.figure.plot([c.isChecked() for c in self.chkbx], self.normalize, - 1 if start is None else start, - self.parse(self.edit_ymin, float), self.parse(self.edit_ymax, float), - update=update) + self.figure.plot( + [c.isChecked() for c in self.chkbx], + self.normalize, + 1 if start is None else start, + self.parse(self.edit_ymin, float), + self.parse(self.edit_ymax, float), + update=update, + ) # ################################### # def plot_data(self): @@ -419,8 +460,7 @@ def changedata(self): # ############################################################### # # ############################################################### # class MyTableWidget(QWidget): - """ holds the tabs and the shared file name and update timer - """ + """holds the tabs and the shared file name and update timer""" def __init__(self): @@ -433,10 +473,12 @@ def __init__(self): self.tabs = QTabWidget() self.tabs.resize(600, 600) - self.plottabs = [PlotTab(self, RESIDUAL_PATTERN, 'residuals', 'Convergence'), - PlotTab(self, COEFFICIENT_PATTERN, 'coefficients', 'Coefficients')] - self.tabs.addTab(self.plottabs[0], 'Convergence plot') - self.tabs.addTab(self.plottabs[1], 'Coefficients plot') + self.plottabs = [ + PlotTab(self, RESIDUAL_PATTERN, "residuals", "Convergence"), + PlotTab(self, COEFFICIENT_PATTERN, "coefficients", "Coefficients"), + ] + self.tabs.addTab(self.plottabs[0], "Convergence plot") + self.tabs.addTab(self.plottabs[1], "Coefficients plot") self.layout.addWidget(self.tabs) self.setLayout(self.layout) @@ -451,9 +493,12 @@ def __init__(self): # file dialog for monitor input def getfile(self): - fname = QFileDialog.getOpenFileName(self, 'Open file', 'history.csv', filter='csv files (*.csv)\n' - 'dat files (*.dat)\n' - 'all files (*)') + fname = QFileDialog.getOpenFileName( + self, + "Open file", + "history.csv", + filter="csv files (*.csv)\n" "dat files (*.dat)\n" "all files (*)", + ) if fname[0] == "": return @@ -482,11 +527,11 @@ def plot_data(self): # ############################################################### # # ############################################################### # # ############################################################### # -if __name__ == '__main__': +if __name__ == "__main__": def run_app(): app = QApplication(sys.argv) - app.setDesktopFileName('su2-monitor') + app.setDesktopFileName("su2-monitor") app.setWindowIcon(QIcon(LOGO_FILE)) mainWin = MainApp() mainWin.show() diff --git a/meson.py b/meson.py index ca03e47b3f0c..467dce5055d5 100755 --- a/meson.py +++ b/meson.py @@ -30,7 +30,6 @@ sys.path.append(sys.path[0]) import preconfigure - if __name__ == "__main__": if sys.version_info[0] < 3: raise Exception("Script must be run using Python 3") From f50e93be04fe2dc4c5c9ff7dae5ab08f939e09e5 Mon Sep 17 00:00:00 2001 From: bigfooted Date: Fri, 23 Oct 2026 16:22:37 +0200 Subject: [PATCH 04/11] Revert unrelated pre-commit reformatting and black bump Co-Authored-By: Claude Sonnet 5.5 --- .pre-commit-config.yaml | 2 +- SU2_PY/FSI_tools/FSIInterface.py | 12 ++++---- SU2_PY/SU2/eval/functions.py | 1 + SU2_PY/SU2/io/filelock.py | 1 - SU2_PY/SU2/io/redirect.py | 1 - SU2_PY/SU2/io/state.py | 1 + SU2_PY/SU2/io/tools.py | 10 ++----- SU2_PY/SU2/opt/scipy_tools.py | 1 + SU2_PY/SU2/run/deform.py | 1 + SU2_PY/SU2/run/direct.py | 6 ++-- SU2_PY/SU2/run/projection.py | 1 + SU2_PY/SU2/util/bunch.py | 48 +++++++++++++++---------------- SU2_PY/SU2/util/filter_adjoint.py | 2 +- SU2_PY/SU2/util/ordered_bunch.py | 48 +++++++++++++++---------------- SU2_PY/SU2/util/ordered_dict.py | 6 ++-- SU2_PY/aeroacoustics.py | 1 + SU2_PY/topology_optimization.py | 1 - meson.py | 1 + 18 files changed, 72 insertions(+), 72 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9e9f8d307ce..1e55bb83a103 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: types_or: [c++, c] # black repo for python formatting - repo: https://github.com/ambv/black - rev: 26.10.1 + rev: 22.6.0 hooks: - id: black exclude: | diff --git a/SU2_PY/FSI_tools/FSIInterface.py b/SU2_PY/FSI_tools/FSIInterface.py index bf5eabe16dc2..01f2fa2e14bc 100644 --- a/SU2_PY/FSI_tools/FSIInterface.py +++ b/SU2_PY/FSI_tools/FSIInterface.py @@ -478,9 +478,9 @@ def connect(self, FSI_config, FluidSolver, SolidSolver): ) del sendBuffPhysical else: - self.fluidPhysicalInterfaceNodesDistribution[0] = ( - self.nFluidInterfacePhysicalNodes - ) + self.fluidPhysicalInterfaceNodesDistribution[ + 0 + ] = self.nFluidInterfacePhysicalNodes # Same thing for the solid part self.solidPhysicalInterfaceNodesDistribution = np.zeros(MPIsize, dtype=int) @@ -491,9 +491,9 @@ def connect(self, FSI_config, FluidSolver, SolidSolver): ) del sendBuffPhysical else: - self.solidPhysicalInterfaceNodesDistribution[0] = ( - self.nSolidInterfacePhysicalNodes - ) + self.solidPhysicalInterfaceNodesDistribution[ + 0 + ] = self.nSolidInterfacePhysicalNodes # --- Calculate and store the global indexing of interface physical nodes on each processor and allgather the information --- if self.have_MPI: diff --git a/SU2_PY/SU2/eval/functions.py b/SU2_PY/SU2/eval/functions.py index 7f6bf4ee41cf..fd6c1103a519 100644 --- a/SU2_PY/SU2/eval/functions.py +++ b/SU2_PY/SU2/eval/functions.py @@ -35,6 +35,7 @@ from .. import util as su2util from ..io import redirect_folder, redirect_output + # ---------------------------------------------------------------------- # Main Function Interface # ---------------------------------------------------------------------- diff --git a/SU2_PY/SU2/io/filelock.py b/SU2_PY/SU2/io/filelock.py index e1101031e78a..45190e1711a0 100644 --- a/SU2_PY/SU2/io/filelock.py +++ b/SU2_PY/SU2/io/filelock.py @@ -28,7 +28,6 @@ import os, time, errno from random import random - # ------------------------------------------------------------------- # File Lock Class # ------------------------------------------------------------------- diff --git a/SU2_PY/SU2/io/redirect.py b/SU2_PY/SU2/io/redirect.py index ff1b7a5d4ea1..161d4cc31f0c 100644 --- a/SU2_PY/SU2/io/redirect.py +++ b/SU2_PY/SU2/io/redirect.py @@ -32,7 +32,6 @@ import os, sys, shutil, copy, glob from .tools import add_suffix, make_link, expand_part - # ------------------------------------------------------------------- # Output Redirection # ------------------------------------------------------------------- diff --git a/SU2_PY/SU2/io/state.py b/SU2_PY/SU2/io/state.py index d904258e8e60..f67a9004b81d 100644 --- a/SU2_PY/SU2/io/state.py +++ b/SU2_PY/SU2/io/state.py @@ -44,6 +44,7 @@ from ..util import bunch from ..util import ordered_bunch + # ---------------------------------------------------------------------- # State Factory # ---------------------------------------------------------------------- diff --git a/SU2_PY/SU2/io/tools.py b/SU2_PY/SU2/io/tools.py index 237ac021002f..aaeccf9433fc 100755 --- a/SU2_PY/SU2/io/tools.py +++ b/SU2_PY/SU2/io/tools.py @@ -302,7 +302,6 @@ def getTurboPerfIndex(nZones=1): "EFFICIENCY": "CL/CD", } - # ------------------------------------------------------------------- # Include per-surface output from History File # ------------------------------------------------------------------- @@ -362,9 +361,9 @@ def read_aerodynamics( historyOutFields[this_objfun]["TYPE"] == "COEFFICIENT" or historyOutFields[this_objfun]["TYPE"] == "D_COEFFICIENT" ): - Func_Values[this_objfun + "[" + str(iZone) + "]"] = ( - history_data[this_objfun + "[" + str(iZone) + "]"] - ) + Func_Values[ + this_objfun + "[" + str(iZone) + "]" + ] = history_data[this_objfun + "[" + str(iZone) + "]"] if "TIME_MARCHING" in special_cases: # for unsteady cases, average time-accurate objective function values @@ -627,7 +626,6 @@ def get_dvMap(): #: def get_dvMap() - # ------------------------------------------------------------------- # Get Design Variable Kind Name from ID # ------------------------------------------------------------------- @@ -642,7 +640,6 @@ def get_dvKind(kindID): # def get_dvKind() - # ------------------------------------------------------------------- # Get Design Variable Kind ID from Name # ------------------------------------------------------------------- @@ -999,7 +996,6 @@ def get_specialCases(config): #: def get_specialCases() - # ------------------------------------------------------------------- # Check Fluid Structure Interaction # ------------------------------------------------------------------- diff --git a/SU2_PY/SU2/opt/scipy_tools.py b/SU2_PY/SU2/opt/scipy_tools.py index 1029bc6f7df4..e8551b67ac08 100644 --- a/SU2_PY/SU2/opt/scipy_tools.py +++ b/SU2_PY/SU2/opt/scipy_tools.py @@ -34,6 +34,7 @@ from .. import eval as su2eval from numpy import array, zeros + # ------------------------------------------------------------------- # Scipy SLSQP # ------------------------------------------------------------------- diff --git a/SU2_PY/SU2/run/deform.py b/SU2_PY/SU2/run/deform.py index 7feb9feaef91..8ead56f11509 100644 --- a/SU2_PY/SU2/run/deform.py +++ b/SU2_PY/SU2/run/deform.py @@ -34,6 +34,7 @@ from .. import io as su2io from .interface import DEF as SU2_DEF + # ---------------------------------------------------------------------- # Mesh Deformation # ---------------------------------------------------------------------- diff --git a/SU2_PY/SU2/run/direct.py b/SU2_PY/SU2/run/direct.py index f6f91089e513..60825cae7a63 100644 --- a/SU2_PY/SU2/run/direct.py +++ b/SU2_PY/SU2/run/direct.py @@ -93,9 +93,9 @@ def direct(config): # check for 'RESTART_ITER' is to avoid forced restart situation in "compute_polar.py"... if konfig.get("RESTART_SOL", "NO") == "YES" and konfig.get("RESTART_ITER", 1) != 1: if konfig.get("CONFIG_LIST", []) != []: - konfig["CONV_FILENAME"] = ( - "config_CFD" # master cfg is always config_CFD. Hardcoded names are prob nt ideal. - ) + konfig[ + "CONV_FILENAME" + ] = "config_CFD" # master cfg is always config_CFD. Hardcoded names are prob nt ideal. restart_iter = "_" + str(konfig["RESTART_ITER"]).zfill(5) history_filename = konfig["CONV_FILENAME"] + restart_iter + plot_extension else: diff --git a/SU2_PY/SU2/run/projection.py b/SU2_PY/SU2/run/projection.py index 4811c947a6a1..80741ba70759 100644 --- a/SU2_PY/SU2/run/projection.py +++ b/SU2_PY/SU2/run/projection.py @@ -35,6 +35,7 @@ from .. import util as su2util from .interface import DOT as SU2_DOT + # ---------------------------------------------------------------------- # Gradient Projection # ---------------------------------------------------------------------- diff --git a/SU2_PY/SU2/util/bunch.py b/SU2_PY/SU2/util/bunch.py index 44afe34f8eee..156e681e7f78 100644 --- a/SU2_PY/SU2/util/bunch.py +++ b/SU2_PY/SU2/util/bunch.py @@ -1,29 +1,29 @@ #!/usr/bin/env python -"""Bunch is a subclass of dict with attribute-style access. - ->>> b = Bunch() ->>> b.hello = 'world' ->>> b.hello -'world' ->>> b['hello'] += "!" ->>> b.hello -'world!' ->>> b.foo = Bunch(lol=True) ->>> b.foo.lol -True ->>> b.foo is b['foo'] -True - -It is safe to import * from this module: - - __all__ = ('Bunch', 'bunchify','unbunchify') - -un/bunchify provide dictionary conversion; Bunches can also be -converted via Bunch.to/fromDict(). - -original source: -https://pypi.python.org/pypi/bunch +""" Bunch is a subclass of dict with attribute-style access. + + >>> b = Bunch() + >>> b.hello = 'world' + >>> b.hello + 'world' + >>> b['hello'] += "!" + >>> b.hello + 'world!' + >>> b.foo = Bunch(lol=True) + >>> b.foo.lol + True + >>> b.foo is b['foo'] + True + + It is safe to import * from this module: + + __all__ = ('Bunch', 'bunchify','unbunchify') + + un/bunchify provide dictionary conversion; Bunches can also be + converted via Bunch.to/fromDict(). + + original source: + https://pypi.python.org/pypi/bunch """ diff --git a/SU2_PY/SU2/util/filter_adjoint.py b/SU2_PY/SU2/util/filter_adjoint.py index c290812786bf..d3a541bbc6bf 100644 --- a/SU2_PY/SU2/util/filter_adjoint.py +++ b/SU2_PY/SU2/util/filter_adjoint.py @@ -79,7 +79,7 @@ def main(): metavar="CHORD", ) - options, args = parser.parse_args() + (options, args) = parser.parse_args() options.chord_length = float(options.chord_length) # run filter diff --git a/SU2_PY/SU2/util/ordered_bunch.py b/SU2_PY/SU2/util/ordered_bunch.py index df8c4bf26172..7aa0f33427de 100644 --- a/SU2_PY/SU2/util/ordered_bunch.py +++ b/SU2_PY/SU2/util/ordered_bunch.py @@ -1,29 +1,29 @@ #!/usr/bin/env python -"""OrderedBunch is a subclass of OrderedDict with attribute-style access. - ->>> b = OrderedBunch() ->>> b.hello = 'world' ->>> b.hello -'world' ->>> b['hello'] += "!" ->>> b.hello -'world!' ->>> b.foo = OrderedBunch(lol=True) ->>> b.foo.lol -True ->>> b.foo is b['foo'] -True - -It is safe to import * from this module: - - __all__ = ('OrderedBunch', 'ordered_bunchify','ordered_unbunchify') - -ordered_un/bunchify provide dictionary conversion; Bunches can also be -converted via OrderedBunch.to/fromOrderedDict(). - -original source: -https://pypi.python.org/pypi/bunch +""" OrderedBunch is a subclass of OrderedDict with attribute-style access. + + >>> b = OrderedBunch() + >>> b.hello = 'world' + >>> b.hello + 'world' + >>> b['hello'] += "!" + >>> b.hello + 'world!' + >>> b.foo = OrderedBunch(lol=True) + >>> b.foo.lol + True + >>> b.foo is b['foo'] + True + + It is safe to import * from this module: + + __all__ = ('OrderedBunch', 'ordered_bunchify','ordered_unbunchify') + + ordered_un/bunchify provide dictionary conversion; Bunches can also be + converted via OrderedBunch.to/fromOrderedDict(). + + original source: + https://pypi.python.org/pypi/bunch """ from .ordered_dict import OrderedDict diff --git a/SU2_PY/SU2/util/ordered_dict.py b/SU2_PY/SU2/util/ordered_dict.py index b4fbe21066f7..776aa1ca55fc 100644 --- a/SU2_PY/SU2/util/ordered_dict.py +++ b/SU2_PY/SU2/util/ordered_dict.py @@ -1,6 +1,6 @@ -"""Backport of OrderedDict() class that runs on Python 2.4, 2.5, 2.6, 2.7 and pypy. -Passes Python2.7's test suite and incorporates all the latest updates. -{{{ http://code.activestate.com/recipes/576693/ (r9) +""" Backport of OrderedDict() class that runs on Python 2.4, 2.5, 2.6, 2.7 and pypy. + Passes Python2.7's test suite and incorporates all the latest updates. + {{{ http://code.activestate.com/recipes/576693/ (r9) """ try: diff --git a/SU2_PY/aeroacoustics.py b/SU2_PY/aeroacoustics.py index ae2aca0f4bba..5243a6668f6e 100755 --- a/SU2_PY/aeroacoustics.py +++ b/SU2_PY/aeroacoustics.py @@ -35,6 +35,7 @@ import numpy as np + WINDOWS = { "hann": np.hanning, "hamming": np.hamming, diff --git a/SU2_PY/topology_optimization.py b/SU2_PY/topology_optimization.py index b8d57c1cebd5..068e006cb878 100755 --- a/SU2_PY/topology_optimization.py +++ b/SU2_PY/topology_optimization.py @@ -225,7 +225,6 @@ def con_der(self, x): ####### Helpers ####### - # updates the parameters in the config files def update_settings(fnames, params): for fname in fnames: diff --git a/meson.py b/meson.py index 467dce5055d5..ca03e47b3f0c 100755 --- a/meson.py +++ b/meson.py @@ -30,6 +30,7 @@ sys.path.append(sys.path[0]) import preconfigure + if __name__ == "__main__": if sys.version_info[0] < 3: raise Exception("Script must be run using Python 3") From 0bd680f2dc5fe61c80fc8a639b3c633ec74e89b9 Mon Sep 17 00:00:00 2001 From: bigfooted Date: Fri, 23 Oct 2026 16:22:41 +0200 Subject: [PATCH 05/11] SU2_MON: fix review findings, add tests and install entries Parse the separate VARIABLES line of TECPLOT histories, plot and filter against the iteration column, advance the reader version on reset, add manufactured-history tests, and install SU2_MON and monitor.py with meson. Co-Authored-By: Claude Sonnet 5.5 --- SU2_PY/meson.build | 7 +++ SU2_PY/tests/test_monitor.py | 101 +++++++++++++++++++++++++++++++++++ bin/monitor.py | 82 ++++++++++++++++++++-------- 3 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 SU2_PY/tests/test_monitor.py diff --git a/SU2_PY/meson.build b/SU2_PY/meson.build index ff0822978077..9511f4d98278 100644 --- a/SU2_PY/meson.build +++ b/SU2_PY/meson.build @@ -74,3 +74,10 @@ install_data(['SU2_Nastran/__init__.py', install_dir: join_paths(get_option('bindir'), 'SU2_Nastran')) install_subdir(['../externals/FADO'], install_dir: get_option('bindir')) + +install_data(['../bin/monitor.py'], + install_dir: join_paths(get_option('bindir'))) + +install_data(['../bin/SU2_MON'], + install_dir: join_paths(get_option('bindir')), + install_mode: 'rwxr-xr-x') diff --git a/SU2_PY/tests/test_monitor.py b/SU2_PY/tests/test_monitor.py new file mode 100644 index 000000000000..e6f5f136070b --- /dev/null +++ b/SU2_PY/tests/test_monitor.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import numpy as np + +for module in ( + "PyQt5", + "PyQt5.QtCore", + "PyQt5.QtGui", + "PyQt5.QtWidgets", + "matplotlib.backends.backend_qt5agg", +): + sys.modules.setdefault(module, mock.MagicMock()) + +MODULE_PATH = Path(__file__).parents[2] / "bin" / "monitor.py" +SPEC = importlib.util.spec_from_file_location("su2_monitor", MODULE_PATH) +MONITOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MONITOR) + +NAMES = '"Inner_Iter","rms[Rho]","rms[RhoE]","CD"' +LEGACY = 'TITLE = "SU2 history"\nVARIABLES = ' + NAMES + '\nZONE T= "Convergence"\n' +TECPLOT = 'TITLE = "SU2 history"\nVARIABLES = \\\n' + NAMES + '\nZONE T= "Convergence"\n' +CSV = NAMES + "\n" + + +def row(i): + return f"{i}, {-1.0 - 0.1 * i}, {-2.0 - 0.1 * i}, 0.5\n" + + +class MonitorTests(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.path = Path(self.dir.name) / "history.csv" + + def tearDown(self): + self.dir.cleanup() + + def reader(self, text): + self.path.write_text(text) + reader = MONITOR.HistoryReader(str(self.path)) + reader.update() + return reader + + def test_header_formats(self): + for header in (LEGACY, TECPLOT, CSV): + reader = self.reader(header + row(0) + row(1)) + self.assertEqual(reader.names, ["rms[Rho]", "rms[RhoE]"], header) + self.assertEqual(reader.n, 2, header) + + def test_incomplete_header_is_deferred(self): + self.path.write_text('TITLE = "x"\nVARIABLES = \\\n"Inner_Iter","rms[Rho]') + reader = MONITOR.HistoryReader(str(self.path)) + self.assertFalse(reader.update()) + self.assertIsNone(reader.cols) + self.path.write_text(TECPLOT + row(0)) + self.assertTrue(reader.update()) + self.assertEqual(reader.n, 1) + + def test_sparse_iterations_and_start_filter(self): + reader = self.reader(CSV + row(0) + row(10) + row(20) + row(30)) + np.testing.assert_array_equal(reader.iters[: reader.n], [0, 10, 20, 30]) + rows = reader.rowsFrom(15) + np.testing.assert_array_equal(reader.iters[rows], [20, 30]) + + def test_incremental_append_and_incomplete_line(self): + reader = self.reader(CSV + row(0) + row(1)) + with open(self.path, "a") as f: + f.write(row(2)[:5]) + self.assertFalse(reader.update()) + self.assertEqual(reader.n, 2) + with open(self.path, "a") as f: + f.write(row(2)[5:] + row(3)) + version = reader.version + self.assertTrue(reader.update()) + self.assertEqual(reader.n, 4) + self.assertGreater(reader.version, version) + + def test_reset_invalidates_cache_key(self): + reader = self.reader(CSV + "".join(row(i) for i in range(50))) + version = reader.version + self.path.write_text(CSV + row(0) + row(1)) + reader.update() + self.assertEqual(reader.n, 2) + self.assertGreater(reader.version, version) + + def test_reset_without_complete_rows_changes_version(self): + reader = self.reader(CSV + "".join(row(i) for i in range(50))) + version = reader.version + self.path.write_text("") + reader.update() + self.assertGreater(reader.version, version) + + +if __name__ == "__main__": + unittest.main() diff --git a/bin/monitor.py b/bin/monitor.py index 4d3109d32067..5ef1c189209a 100755 --- a/bin/monitor.py +++ b/bin/monitor.py @@ -1,4 +1,5 @@ # monitor: displays and updates the residuals of the su2 history file in a window. +# Requires PyQt5, matplotlib, numpy and pandas; launch with 'SU2_MON' next to this file. import csv import io import os.path @@ -44,6 +45,8 @@ RESIDUAL_PATTERN = re.compile("rms|Res") COEFFICIENT_PATTERN = re.compile("^(CD|CL|CMx|CMy|CMz|CFx|CFy|CFz)$") +# x-axis counter and start filter use the first of these columns present in the history +ITERATION_COLUMNS = ("Inner_Iter", "Outer_Iter", "Time_Iter") MAX_POINTS = 4000 READ_BLOCK_BYTES = 32 * 1024 * 1024 UPDATE_INTERVAL_MS = 1000 @@ -63,38 +66,54 @@ class HistoryReader: def __init__(self, filename, pattern=RESIDUAL_PATTERN): self.filename = filename self.pattern = pattern + self.version = 0 self.reset() def reset(self): self.offset = 0 self.cols = None + self.iter_col = None self.names = [] self.buf = np.empty((1024, 0)) + self.iters = np.empty(1024) self.n = 0 - self.version = 0 + self.version += 1 self.readHeader() - # legacy files start with a TITLE line and have a ZONE line after the header + # header is the first line, or the line after TITLE and after a bare 'VARIABLES = \' line; + # an optional ZONE line may follow it def readHeader(self): try: with open(self.filename, "rb") as f: - first = f.readline() - legacy = first.startswith(b"TITLE") - header = f.readline() if legacy else first - if legacy: - f.readline() - if not header.endswith(b"\n"): + line = f.readline() + if line.startswith(b"TITLE"): + line = f.readline() + if line.startswith(b"VARIABLES") and line.partition(b"=")[2].strip() in ( + b"", + b"\\", + ): + line = f.readline() + if not line.endswith(b"\n"): return offset = f.tell() - allnames = next(csv.reader([header.decode()])) + zone = f.readline() + if zone.startswith(b"ZONE") and zone.endswith(b"\n"): + offset = f.tell() + elif zone and not zone.endswith(b"\n") and b"ZONE".startswith(zone): + return + allnames = [cleanName(c) for c in next(csv.reader([line.decode()]))] except (OSError, UnicodeDecodeError, csv.Error, StopIteration): return - self.cols = [ - i for i, c in enumerate(allnames) if self.pattern.search(cleanName(c)) - ] - self.names = [cleanName(allnames[i]) for i in self.cols] + self.cols = [i for i, c in enumerate(allnames) if self.pattern.search(c)] + self.names = [allnames[i] for i in self.cols] + self.iter_col = None + for counter in ITERATION_COLUMNS: + if counter in allnames: + self.iter_col = allnames.index(counter) + break self.buf = np.empty((1024, len(self.cols))) + self.iters = np.empty(1024) self.offset = offset # reads the complete lines appended since the last call; returns True if rows were added @@ -120,30 +139,48 @@ def update(self): if end < 0: break try: - rows = pd.read_csv( + wanted = sorted({*self.cols, *([] if self.iter_col is None else [self.iter_col])}) + table = pd.read_csv( io.BytesIO(chunk[: end + 1]), header=None, - usecols=self.cols, + usecols=wanted, dtype=np.float64, on_bad_lines="skip", - ).to_numpy() + ) + rows = table[self.cols].to_numpy() + iters = ( + table[self.iter_col].to_numpy() + if self.iter_col is not None + else np.arange(self.n, self.n + len(table), dtype=np.float64) + ) except (pd.errors.EmptyDataError, pd.errors.ParserError, ValueError): break self.offset += end + 1 - self.append(rows) + self.append(rows, iters) added = True if added: self.version += 1 return added - def append(self, rows): + # row indices at or after iteration 'start', thinned to at most MAX_POINTS + def rowsFrom(self, start): + rows = np.flatnonzero(self.iters[: self.n] >= start) + rows = rows[rows >= 1] + return rows[:: max(1, -(-len(rows) // MAX_POINTS))] + + def append(self, rows, iters): needed = self.n + len(rows) if needed > len(self.buf): - grown = np.empty((max(needed, 2 * len(self.buf)), self.buf.shape[1])) + size = max(needed, 2 * len(self.buf)) + grown = np.empty((size, self.buf.shape[1])) grown[: self.n] = self.buf[: self.n] self.buf = grown + grownIters = np.empty(size) + grownIters[: self.n] = self.iters[: self.n] + self.iters = grownIters self.buf[self.n : needed] = rows + self.iters[self.n : needed] = iters self.n = needed @@ -214,11 +251,10 @@ def plot(self, listonoff, normalize, start, ymin, ymax, update=True): self.lines = [ax.plot([], [], label=names[l])[0] for l in shown] n = self.reader.n - first = min(max(start, 1), max(n, 1)) - step = max(1, -(-(n - first) // MAX_POINTS)) - x = np.arange(first, n, step) + rows = self.reader.rowsFrom(start) + x = self.reader.iters[rows] for line, l in zip(self.lines, shown): - y = self.reader.buf[first:n:step, l] + y = self.reader.buf[rows, l] if normalize and n > 1 and abs(self.reader.buf[1, l]) > 1e-6: y = 1 + y - self.reader.buf[1, l] line.set_data(x, y) From 6db81b58904d8dfa7f89143d85eb6cc9b43de3d3 Mon Sep 17 00:00:00 2001 From: bigfooted Date: Fri, 23 Oct 2026 16:22:51 +0200 Subject: [PATCH 06/11] Revert unrelated formatting in test_aeroacoustics.py Co-Authored-By: Claude Sonnet 5.5 --- SU2_PY/tests/test_aeroacoustics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/SU2_PY/tests/test_aeroacoustics.py b/SU2_PY/tests/test_aeroacoustics.py index 573141fcf496..31cc242aba7c 100644 --- a/SU2_PY/tests/test_aeroacoustics.py +++ b/SU2_PY/tests/test_aeroacoustics.py @@ -10,6 +10,7 @@ import numpy as np + MODULE_PATH = Path(__file__).parents[1] / "aeroacoustics.py" SPEC = importlib.util.spec_from_file_location("aeroacoustics", MODULE_PATH) AEROACOUSTICS = importlib.util.module_from_spec(SPEC) From 5aeda0134341afd9b001447f067de1cd97d6f4e2 Mon Sep 17 00:00:00 2001 From: bigfooted Date: Fri, 23 Oct 2026 16:33:26 +0200 Subject: [PATCH 07/11] SU2_MON: time-iteration switch, default when Time_Iter is present Co-Authored-By: Claude Sonnet 5.5 --- SU2_PY/tests/test_monitor.py | 11 ++++++ bin/monitor.py | 68 +++++++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/SU2_PY/tests/test_monitor.py b/SU2_PY/tests/test_monitor.py index e6f5f136070b..572ebece4ba8 100644 --- a/SU2_PY/tests/test_monitor.py +++ b/SU2_PY/tests/test_monitor.py @@ -53,6 +53,17 @@ def test_header_formats(self): self.assertEqual(reader.names, ["rms[Rho]", "rms[RhoE]"], header) self.assertEqual(reader.n, 2, header) + def test_time_iterations(self): + names = '"Time_Iter","Inner_Iter","rms[Rho]"\n' + body = "".join(f"{t}, {i}, {-t - 0.1 * i}\n" for t in range(3) for i in range(4)) + reader = self.reader(names + body) + self.assertTrue(reader.hasTime()) + np.testing.assert_array_equal(reader.iters[: reader.n][:5], [0, 1, 2, 3, 0]) + rows = reader.rowsFrom(1, byTime=True) + np.testing.assert_array_equal(reader.times[rows], [1, 2]) + np.testing.assert_array_equal(reader.iters[rows], [3, 3]) + self.assertFalse(self.reader(CSV + row(0) + row(1)).hasTime()) + def test_incomplete_header_is_deferred(self): self.path.write_text('TITLE = "x"\nVARIABLES = \\\n"Inner_Iter","rms[Rho]') reader = MONITOR.HistoryReader(str(self.path)) diff --git a/bin/monitor.py b/bin/monitor.py index 5ef1c189209a..ec1a7af588c9 100755 --- a/bin/monitor.py +++ b/bin/monitor.py @@ -45,8 +45,9 @@ RESIDUAL_PATTERN = re.compile("rms|Res") COEFFICIENT_PATTERN = re.compile("^(CD|CL|CMx|CMy|CMz|CFx|CFy|CFz)$") -# x-axis counter and start filter use the first of these columns present in the history -ITERATION_COLUMNS = ("Inner_Iter", "Outer_Iter", "Time_Iter") +# inner counter: first of these present in the history, else the row index +ITERATION_COLUMNS = ("Inner_Iter", "Outer_Iter") +TIME_COLUMN = "Time_Iter" MAX_POINTS = 4000 READ_BLOCK_BYTES = 32 * 1024 * 1024 UPDATE_INTERVAL_MS = 1000 @@ -73,9 +74,11 @@ def reset(self): self.offset = 0 self.cols = None self.iter_col = None + self.time_col = None self.names = [] self.buf = np.empty((1024, 0)) self.iters = np.empty(1024) + self.times = np.empty(1024) self.n = 0 self.version += 1 self.readHeader() @@ -112,8 +115,10 @@ def readHeader(self): if counter in allnames: self.iter_col = allnames.index(counter) break + self.time_col = allnames.index(TIME_COLUMN) if TIME_COLUMN in allnames else None self.buf = np.empty((1024, len(self.cols))) self.iters = np.empty(1024) + self.times = np.empty(1024) self.offset = offset # reads the complete lines appended since the last call; returns True if rows were added @@ -139,7 +144,9 @@ def update(self): if end < 0: break try: - wanted = sorted({*self.cols, *([] if self.iter_col is None else [self.iter_col])}) + wanted = sorted( + {*self.cols, *(c for c in (self.iter_col, self.time_col) if c is not None)} + ) table = pd.read_csv( io.BytesIO(chunk[: end + 1]), header=None, @@ -153,23 +160,37 @@ def update(self): if self.iter_col is not None else np.arange(self.n, self.n + len(table), dtype=np.float64) ) + times = ( + table[self.time_col].to_numpy() + if self.time_col is not None + else iters + ) except (pd.errors.EmptyDataError, pd.errors.ParserError, ValueError): break self.offset += end + 1 - self.append(rows, iters) + self.append(rows, iters, times) added = True if added: self.version += 1 return added - # row indices at or after iteration 'start', thinned to at most MAX_POINTS - def rowsFrom(self, start): - rows = np.flatnonzero(self.iters[: self.n] >= start) + def hasTime(self): + return self.time_col is not None + + # rows at or after iteration 'start', thinned to at most MAX_POINTS; + # by time step, only the last row of each Time_Iter is kept + def rowsFrom(self, start, byTime=False): + counter = self.times if byTime and self.hasTime() else self.iters + counter = counter[: self.n] + keep = counter >= start + if byTime and self.hasTime(): + keep[:-1] &= counter[1:] != counter[:-1] + rows = np.flatnonzero(keep) rows = rows[rows >= 1] return rows[:: max(1, -(-len(rows) // MAX_POINTS))] - def append(self, rows, iters): + def append(self, rows, iters, times): needed = self.n + len(rows) if needed > len(self.buf): size = max(needed, 2 * len(self.buf)) @@ -179,8 +200,12 @@ def append(self, rows, iters): grownIters = np.empty(size) grownIters[: self.n] = self.iters[: self.n] self.iters = grownIters + grownTimes = np.empty(size) + grownTimes[: self.n] = self.times[: self.n] + self.times = grownTimes self.buf[self.n : needed] = rows self.iters[self.n : needed] = iters + self.times[self.n : needed] = times self.n = needed @@ -227,13 +252,13 @@ def names(self): return self.reader.names if self.reader is not None else [] # plots the residuals from iteration 'start' on; ymin/ymax of None means automatic range - def plot(self, listonoff, normalize, start, ymin, ymax, update=True): + def plot(self, listonoff, normalize, start, ymin, ymax, byTime=False, update=True): if self.reader is None: return if update: self.reader.update() - key = (tuple(listonoff), normalize, start, ymin, ymax, self.reader.version) + key = (tuple(listonoff), normalize, start, ymin, ymax, byTime, self.reader.version) if key == self.lastkey: return self.lastkey = key @@ -246,13 +271,13 @@ def plot(self, listonoff, normalize, start, ymin, ymax, update=True): if rebuild: self.visible = [names[l] for l in shown] ax.clear() - ax.set_xlabel("iterations") + ax.set_xlabel("time iterations" if byTime and self.reader.hasTime() else "iterations") ax.set_ylabel(self.ylabel) self.lines = [ax.plot([], [], label=names[l])[0] for l in shown] n = self.reader.n - rows = self.reader.rowsFrom(start) - x = self.reader.iters[rows] + rows = self.reader.rowsFrom(start, byTime) + x = (self.reader.times if byTime and self.reader.hasTime() else self.reader.iters)[rows] for line, l in zip(self.lines, shown): y = self.reader.buf[rows, l] if normalize and n > 1 and abs(self.reader.buf[1, l]) > 1e-6: @@ -343,6 +368,14 @@ def setFile(self, filename, label, tooltip): self.update_chkbx(self.figure.names()) self.plot_data() + # the switch is enabled, and on by default, only when the history has a Time_Iter column + def updateTimeSwitch(self): + reader = self.figure.reader + has = reader is not None and reader.hasTime() + if has != self.btn_time.isEnabled() or not has: + self.btn_time.setEnabled(has) + self.btn_time.setChecked(has) + def setRunning(self, running): self.btn_plot_data.setText("stop" if running else "start") self.btn_plot_data.setToolTip( @@ -427,12 +460,19 @@ def make_tab(self, title): self.chk_layout = QVBoxLayout() + self.btn_time = QCheckBox("Time iterations") + self.btn_time.setToolTip( + "Plot against Time_Iter (last inner iteration of each step) instead of inner iterations" + ) + self.btn_time.stateChanged.connect(lambda _: self.changedata()) + # now we add all the buttons in a vertical list self.btn_layout = QVBoxLayout() self.btn_layout.addWidget(self.btn_openfile) self.btn_layout.addWidget(self.show_filename) self.btn_layout.addWidget(self.btn_plot_data) self.btn_layout.addWidget(self.btn_normalize) + self.btn_layout.addWidget(self.btn_time) self.btn_layout.addLayout(range_layout) self.btn_layout.addLayout(self.chk_layout) @@ -474,6 +514,7 @@ def draw(self, update): 1 if start is None else start, self.parse(self.edit_ymin, float), self.parse(self.edit_ymax, float), + self.btn_time.isChecked(), update=update, ) @@ -483,6 +524,7 @@ def plot_data(self): self.figure.reader.update() if self.figure.names() != self.chkbx_names: self.update_chkbx(self.figure.names()) + self.updateTimeSwitch() self.draw(update=False) # ################################### # From 4d17f159e8a17f0293ba3eae62b6edb31f172560 Mon Sep 17 00:00:00 2001 From: bigfooted Date: Fri, 23 Oct 2026 16:43:15 +0200 Subject: [PATCH 08/11] SU2_MON: import matplotlib.figure explicitly Co-Authored-By: Claude Sonnet 5.5 --- bin/monitor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/monitor.py b/bin/monitor.py index ec1a7af588c9..98e2ca5adb57 100755 --- a/bin/monitor.py +++ b/bin/monitor.py @@ -7,6 +7,7 @@ import sys import matplotlib +import matplotlib.figure import numpy as np import pandas as pd from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas From b30f917b06da107ffada0402cda0f3c59aae0924 Mon Sep 17 00:00:00 2001 From: bigfooted Date: Fri, 23 Oct 2026 16:46:05 +0200 Subject: [PATCH 09/11] SU2_MON: silence the KDE ThumbCreator service warning Co-Authored-By: Claude Sonnet 5.5 --- bin/monitor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bin/monitor.py b/bin/monitor.py index 98e2ca5adb57..2cead8b8fb64 100755 --- a/bin/monitor.py +++ b/bin/monitor.py @@ -55,6 +55,12 @@ LOGO_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logoSU2.png") +# silences the KDE file dialog warning about the missing ThumbCreator service +def silenceKdeServiceWarnings(): + rules = os.environ.get("QT_LOGGING_RULES", "") + os.environ["QT_LOGGING_RULES"] = (rules + ";" if rules else "") + "kf.service.services=false" + + def cleanName(name): return name.replace("VARIABLES=", "").replace('"', "").replace(" ", "") @@ -608,6 +614,7 @@ def plot_data(self): if __name__ == "__main__": def run_app(): + silenceKdeServiceWarnings() app = QApplication(sys.argv) app.setDesktopFileName("su2-monitor") app.setWindowIcon(QIcon(LOGO_FILE)) From fcbc647120628417d052cc3f6bdd4f7b0a718c48 Mon Sep 17 00:00:00 2001 From: Nijso Date: Fri, 23 Oct 2026 16:54:03 +0200 Subject: [PATCH 10/11] Potential fix for pull request finding 'CodeQL / Module is imported with 'import' and 'import from'' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- SU2_PY/tests/test_monitor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/SU2_PY/tests/test_monitor.py b/SU2_PY/tests/test_monitor.py index 572ebece4ba8..04bac50c7092 100644 --- a/SU2_PY/tests/test_monitor.py +++ b/SU2_PY/tests/test_monitor.py @@ -5,7 +5,6 @@ import tempfile import unittest from pathlib import Path -from unittest import mock import numpy as np @@ -16,7 +15,7 @@ "PyQt5.QtWidgets", "matplotlib.backends.backend_qt5agg", ): - sys.modules.setdefault(module, mock.MagicMock()) + sys.modules.setdefault(module, unittest.mock.MagicMock()) MODULE_PATH = Path(__file__).parents[2] / "bin" / "monitor.py" SPEC = importlib.util.spec_from_file_location("su2_monitor", MODULE_PATH) From 4b9af074a3c2b07cb95d55d95e1336e7c2290469 Mon Sep 17 00:00:00 2001 From: bigfooted Date: Fri, 23 Oct 2026 16:56:13 +0200 Subject: [PATCH 11/11] SU2_MON: black formatting Co-Authored-By: Claude Sonnet 5.5 --- SU2_PY/tests/test_monitor.py | 8 ++++++-- bin/monitor.py | 33 ++++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/SU2_PY/tests/test_monitor.py b/SU2_PY/tests/test_monitor.py index 572ebece4ba8..ae31b7cf520f 100644 --- a/SU2_PY/tests/test_monitor.py +++ b/SU2_PY/tests/test_monitor.py @@ -25,7 +25,9 @@ NAMES = '"Inner_Iter","rms[Rho]","rms[RhoE]","CD"' LEGACY = 'TITLE = "SU2 history"\nVARIABLES = ' + NAMES + '\nZONE T= "Convergence"\n' -TECPLOT = 'TITLE = "SU2 history"\nVARIABLES = \\\n' + NAMES + '\nZONE T= "Convergence"\n' +TECPLOT = ( + 'TITLE = "SU2 history"\nVARIABLES = \\\n' + NAMES + '\nZONE T= "Convergence"\n' +) CSV = NAMES + "\n" @@ -55,7 +57,9 @@ def test_header_formats(self): def test_time_iterations(self): names = '"Time_Iter","Inner_Iter","rms[Rho]"\n' - body = "".join(f"{t}, {i}, {-t - 0.1 * i}\n" for t in range(3) for i in range(4)) + body = "".join( + f"{t}, {i}, {-t - 0.1 * i}\n" for t in range(3) for i in range(4) + ) reader = self.reader(names + body) self.assertTrue(reader.hasTime()) np.testing.assert_array_equal(reader.iters[: reader.n][:5], [0, 1, 2, 3, 0]) diff --git a/bin/monitor.py b/bin/monitor.py index 2cead8b8fb64..0764f7631794 100755 --- a/bin/monitor.py +++ b/bin/monitor.py @@ -58,7 +58,9 @@ # silences the KDE file dialog warning about the missing ThumbCreator service def silenceKdeServiceWarnings(): rules = os.environ.get("QT_LOGGING_RULES", "") - os.environ["QT_LOGGING_RULES"] = (rules + ";" if rules else "") + "kf.service.services=false" + os.environ["QT_LOGGING_RULES"] = ( + rules + ";" if rules else "" + ) + "kf.service.services=false" def cleanName(name): @@ -98,10 +100,8 @@ def readHeader(self): line = f.readline() if line.startswith(b"TITLE"): line = f.readline() - if line.startswith(b"VARIABLES") and line.partition(b"=")[2].strip() in ( - b"", - b"\\", - ): + bare = line.partition(b"=")[2].strip() in (b"", b"\\") + if line.startswith(b"VARIABLES") and bare: line = f.readline() if not line.endswith(b"\n"): return @@ -152,7 +152,10 @@ def update(self): break try: wanted = sorted( - {*self.cols, *(c for c in (self.iter_col, self.time_col) if c is not None)} + { + *self.cols, + *(c for c in (self.iter_col, self.time_col) if c is not None), + } ) table = pd.read_csv( io.BytesIO(chunk[: end + 1]), @@ -265,7 +268,15 @@ def plot(self, listonoff, normalize, start, ymin, ymax, byTime=False, update=Tru if update: self.reader.update() - key = (tuple(listonoff), normalize, start, ymin, ymax, byTime, self.reader.version) + key = ( + tuple(listonoff), + normalize, + start, + ymin, + ymax, + byTime, + self.reader.version, + ) if key == self.lastkey: return self.lastkey = key @@ -278,13 +289,17 @@ def plot(self, listonoff, normalize, start, ymin, ymax, byTime=False, update=Tru if rebuild: self.visible = [names[l] for l in shown] ax.clear() - ax.set_xlabel("time iterations" if byTime and self.reader.hasTime() else "iterations") + ax.set_xlabel( + "time iterations" if byTime and self.reader.hasTime() else "iterations" + ) ax.set_ylabel(self.ylabel) self.lines = [ax.plot([], [], label=names[l])[0] for l in shown] n = self.reader.n rows = self.reader.rowsFrom(start, byTime) - x = (self.reader.times if byTime and self.reader.hasTime() else self.reader.iters)[rows] + x = ( + self.reader.times if byTime and self.reader.hasTime() else self.reader.iters + )[rows] for line, l in zip(self.lines, shown): y = self.reader.buf[rows, l] if normalize and n > 1 and abs(self.reader.buf[1, l]) > 1e-6: