Skip to content

visualdynamics.gui.run_reports

run_reports

Reports from runs: the batch of one-call reports, asked in the window.

visualdynamics.run_report writes the report each controller run's type calls for, one per run, and a script hands it the runs, a geometry, a destination and a marking (Brandon, 2026-10-04: "We made functions to batch process multi-selected nc4 files. Can we make a way to call those from the application?"). This dialog asks for the same four things, so File → Reports from Runs… is that call and nothing else: the window runs run_report with the answers, on its long-verb runner, with the bar and Cancel.

The runs are listed with the report each one will get, read from the file the way run_report reads it (report_kind), so a campaign's odd one out — a modal survey in a folder of random runs — is seen and left out here rather than refusing the batch after the button.

Classes:

Name Description
RunReportsDialog

The runs, the report each gets and how much of it to read, the

Functions:

Name Description
run_kind

(kind, what the list says) for one run: the one-call report it

ask_run_reports

The dialog, modal; its choices, or None when cancelled.

Classes

RunReportsDialog

RunReportsDialog(runs: list[str], parent: QWidget | None = None)

Bases: QDialog

The runs, the report each gets and how much of it to read, the geometry, where the reports go, and their marking.

choices() is the answer in run_report's own keywords: runs (the ones not left out), kinds and last (each by run), path (a folder, or None for beside each run), geometry (a path or None) and marking.

Methods:

Name Description
choices

The answer, in run_report's keywords.

Source code in src/visualdynamics/gui/run_reports.py
def __init__(self, runs: list[str], parent: QWidget | None = None) -> None:
    super().__init__(parent)
    self.setWindowTitle('Reports from Runs')
    self.kinds: list[tuple[str, str | None, str]] = [
        (path, *run_kind(path)) for path in runs]
    self.geometry: str | None = None
    self.folder: str | None = None

    layout = QVBoxLayout(self)
    self.heading: QLabel = QLabel()
    layout.addWidget(self.heading)

    # a run a row: its name, the report it gets (detected, or Leave
    # out when it has none) and how much of its end to read
    self.table: QTableWidget = QTableWidget(len(self.kinds), 3)
    self.table.setHorizontalHeaderLabels(['Run', 'Report', 'Last'])
    self.table.verticalHeader().setVisible(False)
    self.table.horizontalHeader().setSectionResizeMode(
        0, QHeaderView.ResizeMode.Stretch)
    self.reports: list[QComboBox] = []
    self.lasts: list[QDoubleSpinBox] = []
    for row, (path, kind, said) in enumerate(self.kinds):
        name = QTableWidgetItem(os.path.basename(path) if kind
                                else f'{os.path.basename(path)} — {said}')
        # the reason in full: a narrow column cuts it off
        name.setToolTip(path if kind else f'{path}\n{said}')
        name.setFlags(name.flags() & ~Qt.ItemFlag.ItemIsEditable)
        self.table.setItem(row, 0, name)
        box = QComboBox()
        for label, value in CHOICES:
            box.addItem(label, value)
        # seen and left out, not hidden: the reader should know
        # which file of the campaign was passed over, and why
        box.setCurrentIndex(box.findData(kind) if kind
                            else len(CHOICES) - 1)
        box.currentIndexChanged.connect(self._restate)
        self.table.setCellWidget(row, 1, box)
        self.reports.append(box)
        span = _seconds_box()
        self.table.setCellWidget(row, 2, span)
        self.lasts.append(span)
    self.table.resizeColumnsToContents()
    layout.addWidget(self.table)

    # most random reports want only the end of the run: one number
    # for all of them, each row still its own to change
    self.every_random: QDoubleSpinBox = _seconds_box()
    self.every_random.valueChanged.connect(self._last_for_random)
    every = QHBoxLayout()
    every.addWidget(QLabel('Last, for every random report'))
    every.addWidget(self.every_random)
    every.addStretch(1)
    layout.addLayout(every)

    form = QFormLayout()
    self.geometry_label: QLabel = QLabel('None')
    choose_geometry = QPushButton('Choose…')
    choose_geometry.clicked.connect(self._choose_geometry)
    clear_geometry = QPushButton('None')
    clear_geometry.clicked.connect(lambda: self._set_geometry(None))
    row = QHBoxLayout()
    row.addWidget(self.geometry_label, 1)
    row.addWidget(choose_geometry)
    row.addWidget(clear_geometry)
    form.addRow('Geometry', row)

    # beside each run is what `run_report` does with no path, so it
    # is the default here too; one folder is the other answer
    self.beside: QRadioButton = QRadioButton('Beside each run')
    self.beside.setChecked(True)
    self.in_folder: QRadioButton = QRadioButton('In one folder')
    self.folder_label: QLabel = QLabel('')
    choose_folder = QPushButton('Choose…')
    choose_folder.clicked.connect(self._choose_folder)
    self.in_folder.toggled.connect(
        lambda on: on and self.folder is None and self._choose_folder())
    where = QVBoxLayout()
    where.addWidget(self.beside)
    folder_row = QHBoxLayout()
    folder_row.addWidget(self.in_folder)
    folder_row.addWidget(self.folder_label, 1)
    folder_row.addWidget(choose_folder)
    where.addLayout(folder_row)
    form.addRow('Write', where)

    self.marking: QLineEdit = QLineEdit(DEFAULT_MARKING)
    self.marking.setToolTip('The banner across the top and bottom of '
                            'every page of every report')
    form.addRow('Marking', self.marking)
    layout.addLayout(form)

    buttons = QDialogButtonBox()
    self.write_button: QPushButton = buttons.addButton(
        '', QDialogButtonBox.ButtonRole.AcceptRole)
    buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
    buttons.accepted.connect(self.accept)
    buttons.rejected.connect(self.reject)
    layout.addWidget(buttons)
    self._restate()
Methods:
choices
choices() -> dict[str, Any]

The answer, in run_report's keywords.

Source code in src/visualdynamics/gui/run_reports.py
def choices(self) -> dict[str, Any]:
    """The answer, in `run_report`'s keywords."""
    picked = [row for row in self._picked() if row[1]]
    return {
        'runs': [path for path, _kind, _last in picked],
        'kinds': {path: kind for path, kind, _last in picked},
        'last': {path: last for path, _kind, last in picked},
        # a trailing separator: `run_report` reads a path ending in
        # one as a folder, existing or not
        'path': (os.path.join(self.folder, '')
                 if self.in_folder.isChecked() and self.folder else None),
        'geometry': self.geometry,
        'marking': self.marking.text().strip() or DEFAULT_MARKING,
    }

Functions:

run_kind

run_kind(path: str) -> tuple[str | None, str]

(kind, what the list says) for one run: the one-call report it gets, or None and why it gets none.

Source code in src/visualdynamics/gui/run_reports.py
def run_kind(path: str) -> tuple[str | None, str]:
    """(kind, what the list says) for one run: the one-call report it
    gets, or None and why it gets none."""
    from ..io.rattlesnake import project_type
    from ..project import report_kind

    try:
        kind = report_kind(path)
    except ValueError:
        try:
            declared = project_type(path)
        except (OSError, ValueError):
            declared = None
        return None, (f'{declared}: no one-call report' if declared
                      else 'not a controller run')
    except OSError:
        return None, 'cannot be read'
    return kind, KIND_NAMES[kind]

ask_run_reports

ask_run_reports(parent: QWidget | None, runs: list[str]) -> dict[str, Any] | None

The dialog, modal; its choices, or None when cancelled.

Source code in src/visualdynamics/gui/run_reports.py
def ask_run_reports(parent: QWidget | None, runs: list[str]
                    ) -> dict[str, Any] | None:
    """The dialog, modal; its choices, or None when cancelled."""
    dialog = RunReportsDialog(runs, parent)
    if dialog.exec() == QDialog.DialogCode.Accepted:
        return dialog.choices()
    return None