Source code for spacr.qt.screens.outliers

"""The Outliers screen — which objects are wrong, and which *wells* are wrong.

A thin surface over :mod:`spacr.qt.widgets.outlier_model`, which is where all
the statistics and all the arguments live. The screen's only jobs are to load a
table, collect the four decisions the engine needs (features, method,
threshold, transform), run it off the GUI thread, and show what came back
without recomputing a single number of its own.

Assembles what already exists:

* :func:`spacr.qt.screens.graph_builder.read_table` /
  :func:`~spacr.qt.screens.graph_builder.table_names` — the same CSV/SQLite
  reader the Graph Builder and PCA load through, rather than a third one that
  reads ``measurements.db`` slightly differently;
* :class:`spacr.qt.widgets.pca_view.FeaturePicker` — the same tick list PCA
  uses, offering the same continuous columns by the same classifier, so a user
  who ticked eight features for a decomposition does not meet a different list
  here;
* :class:`spacr.qt.job_runner.JobRunner` — an MCD fit on 200,000 objects is
  seconds of work and must not be seconds of a frozen window.

Two tables, because there are two answers
-----------------------------------------
The **Objects** tab is the per-object flags; the **Wells** tab is the
across-well pass. They are separate tabs rather than one merged view because
they answer different questions and routinely disagree: a well shifted as a
whole flags almost none of its individual objects and is nevertheless the
loudest point among wells. The engine's ``report()`` is shown verbatim in the
third tab, caveats included — the share flagged is not interpretable without
the sentence saying whether a symmetric fence on skewed data produced it.

The object table shows the worst rows first and caps how many it draws; the
number of flagged objects in the header comes from the result, never from the
number of rows the table happens to be showing. Nothing about the *export* is
capped.

Nothing is deleted here
-----------------------
The export writes the whole table with the flag columns added, and separately
the flagged rows on their own. There is no "remove outliers" button: dropping
objects is a decision about the analysis, made in the analysis, and a screen
that offered it as a single click would make it the default.

:func:`register` is **not** called at import; read its docstring.
"""
from __future__ import annotations

import logging
import os
from typing import List, Optional

import pandas as pd
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
    QAbstractItemView, QComboBox, QDoubleSpinBox, QFileDialog,
    QFormLayout, QHBoxLayout, QHeaderView, QLabel, QPlainTextEdit,
    QPushButton, QSpinBox, QTabWidget, QTableWidget,
    QTableWidgetItem, QVBoxLayout, QWidget,
)

from ..job_runner import JobRunner
from ..theme import (RADIUS, SPACING, block_surface, register_widget_qss)
from ..widgets.measurements_example import EXAMPLE_TABLE, install_test_data_button

#: The control column's object name, and what the QSS block below keys off.
CONTROLS_OBJECT = "OutlierControls"



def _outliers_qss(palette: dict, opacity=None) -> str:
    """This screen's QSS block, appended to every generated stylesheet.

    The control column is a named ``QWidget`` and had no rule of its own,
    so it fell through to the blanket ``QWidget {{ background-color: bg }}``
    -- the WINDOW colour, not a surface, which no page-opacity setting can
    reach. It is a page surface now, the same one the Graph Builder's and
    the Trellis's shelves take, and the feature picker inside it is a
    transparent display that shows it through rather than a second panel.
    """
    return f"""
QWidget#{CONTROLS_OBJECT} {{
    background: {block_surface("surface_alt", palette.get("theme"), opacity)};
    border-radius: {RADIUS["md"]}px;
}}
"""


register_widget_qss("Outliers", _outliers_qss, replace=True)
from ..widgets.outlier_model import (
    DEFAULT_ALPHA, DEFAULT_IQR_C, DEFAULT_MAD_K, DEFAULT_MIN_WELL_OBJECTS,
    METHOD_IQR, METHOD_MAD, METHOD_MAHALANOBIS, TRANSFORM_LOG10,
    TRANSFORM_NONE, OutlierSpec, detect_outliers,
)
from ..widgets.collapsible_splitter import CollapsibleSplitter
from ..widgets.pca_view import FeaturePicker
from .graph_builder import read_table, table_names
from .app_screen import ModuleHeader

LOG = logging.getLogger("spacr.qt.screens.outliers")

__all__ = ["OutliersScreen", "make_outliers_screen", "register",
           "APP_KEY", "APP_NAME", "APP_DESCRIPTION", "APP_INTRO",
           "APP_CLI_NOTE", "APP_NAME_TRANSLATIONS", "MAX_TABLE_ROWS"]

#: The registry key. Chosen once and never renamed.
APP_KEY = "outliers"

#: Object rows drawn in the table, worst score first. A QTableWidget with
#: 200,000 rows in it costs a second of layout and answers no question the
#: first few hundred do not — the counts in the header and everything the
#: export writes are over the whole table regardless.
MAX_TABLE_ROWS = 500

#: The method menu, in the order it is offered: the safest default first.
_METHOD_LABELS = (
    (METHOD_MAD, "MAD — modified z against the median"),
    (METHOD_IQR, "IQR — Tukey's fence on the quartiles"),
    (METHOD_MAHALANOBIS, "Mahalanobis — robust multivariate (MCD)"),
)
from ..widgets.toggle import Toggle
from ..widgets.sortable_table import install_sorting, table_item
from ..app_catalog import declared_app, register_declared

#: What the one threshold spinbox means under each method: label, tooltip,
#: range, step, decimals and default.
_THRESHOLD_FIELDS = {
    METHOD_MAD: ("k (robust SDs)",
                 "Flag beyond this many robust SDs from the median. 3.5 is "
                 "the usual choice and is stricter than the 3 a mean/SD rule "
                 "would use, because a robust scale is not inflated by the "
                 "points being tested.",
                 0.1, 20.0, 0.1, 2, DEFAULT_MAD_K),
    METHOD_IQR: ("c (× IQR)",
                 "Flag outside Q1 − c·IQR / Q3 + c·IQR. 1.5 is the box "
                 "plot's whisker; 3.0 is the conventional 'far out' fence.",
                 0.1, 20.0, 0.1, 2, DEFAULT_IQR_C),
    METHOD_MAHALANOBIS: ("α (per object)",
                         "Expected share of CLEAN objects flagged. 0.001 "
                         "means one in a thousand — about 200 false flags "
                         "over 200,000 objects, which the report states.",
                         1e-6, 0.2, 0.0005, 6, DEFAULT_ALPHA),
}


[docs] class OutliersScreen(QWidget): """Load a measurement table, flag the bad objects, and name the bad wells. :param threaded: ``False`` runs the table read and the scan inline, in the same order and through the same signals, so a test can drive the whole screen synchronously without the behaviour diverging. :param parent: parent widget; ownership only. """ #: A scan finished. Carries the #: :class:`~spacr.qt.widgets.outlier_model.OutlierResult`. scanned = Signal(object) #: A read or a scan failed. Carries the message the engine wrote. failed = Signal(str) def __init__(self, parent=None, *, threaded: bool = True): """Build the screen: the controls beside the object, well and report tabs. :param parent: parent widget, or ``None``. :param threaded: read and scan on a worker thread. Set ``False`` in tests so ``scan`` finishes before it returns. """ super().__init__(parent) self.setObjectName("OutliersScreen") self._frame: Optional[pd.DataFrame] = None self._path: Optional[str] = None self._result = None self._objects: Optional[pd.DataFrame] = None self._jobs = JobRunner(self, threaded=threaded, app_key=APP_KEY) self._jobs.job_failed.connect(self._on_job_failed) outer = QVBoxLayout(self) outer.setContentsMargins(SPACING["md"], SPACING["md"], SPACING["md"], SPACING["md"]) outer.setSpacing(SPACING["sm"]) head = QHBoxLayout() head.setContentsMargins(0, 0, 0, 0) head.setSpacing(SPACING["sm"]) header = ModuleHeader( APP_NAME, description=APP_DESCRIPTION, instruction="Load a table, choose the rule and the columns, then scan.", ) self._header = header head.addWidget(header) self._source = QLabel("no table loaded", self) self._source.setObjectName("OutlierSourceLabel") head.addWidget(self._source, 1) self._table_picker = QComboBox(self) self._table_picker.setObjectName("OutlierTablePicker") self._table_picker.setToolTip("Which table of the database to scan") self._table_picker.setVisible(False) self._table_picker.currentTextChanged.connect(self._on_table_picked) head.addWidget(self._table_picker) load = QPushButton("Load table…", self) load.setObjectName("PrimaryButton") load.setToolTip("A measurements.db, or a CSV of measurements") load.clicked.connect(self.choose_table) head.addWidget(load) example = install_test_data_button( self, head, lambda _folder, db: self.load_path( str(db), table=EXAMPLE_TABLE), say=self._source.setText) example.setObjectName("OutliersTestDataButton") self._export = QPushButton("Export…", self) self._export.setToolTip( "Write the whole table with the flag columns added, the flagged " "rows on their own, and the per-well summary") self._export.clicked.connect(self.export_csv) self._export.setEnabled(False) head.addWidget(self._export) outer.addLayout(head) body = CollapsibleSplitter(Qt.Horizontal, self, persist_key=f"{APP_KEY}::body") self._body = body self.tabs = QTabWidget(self) self.tabs.setObjectName("OutlierTabs") self.object_table = self._make_table("OutlierObjectTable") self.tabs.addTab(self.object_table, "Objects") self.well_table = self._make_table("OutlierWellTable") self.tabs.addTab(self.well_table, "Wells") self.report = QPlainTextEdit(self) self.report.setObjectName("OutlierReport") self.report.setReadOnly(True) self.report.setPlaceholderText( "Load a table and press Scan. Everything printed here — the " "counts, the thresholds and the caveats — comes from " "spacr.qt.widgets.outlier_model, which is also the headless " "entry point.") self.tabs.addTab(self.report, "Report") self.results_section = body.add_section( self.tabs, "Results", persist_key=f"{APP_KEY}/Results", stretch=1) self.controls_section = body.add_section( self._build_controls(), "Scan", persist_key=f"{APP_KEY}/Scan", stretch=0) outer.addWidget(body, 1) from ..dnd import install_for install_for(self, "outliers") from .settings_model import retarget_field_tooltips retarget_field_tooltips(self) def _make_table(self, name: str) -> QTableWidget: """A read-only results grid. Two of them, built the same way.""" table = QTableWidget(self) install_sorting(table) table.setObjectName(name) table.setEditTriggers(QAbstractItemView.NoEditTriggers) table.setSelectionBehavior(QAbstractItemView.SelectRows) table.setAlternatingRowColors(True) table.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeToContents) table.verticalHeader().setVisible(False) return table def _build_controls(self) -> QWidget: """The right-hand column: what to test, how, and where the line is.""" panel = QWidget(self) panel.setObjectName(CONTROLS_OBJECT) from ..preferences import scaled_px panel.setMaximumWidth(scaled_px(360)) layout = QVBoxLayout(panel) layout.setContentsMargins(SPACING["sm"], SPACING["sm"], SPACING["sm"], SPACING["sm"]) layout.setSpacing(SPACING["sm"]) self.features = FeaturePicker(panel) layout.addWidget(self.features, 1) form = QFormLayout() form.setContentsMargins(0, 0, 0, 0) form.setSpacing(SPACING["xs"]) self.method = QComboBox(panel) self.method.setObjectName("OutlierMethod") for key, label in _METHOD_LABELS: self.method.addItem(label, key) self.method.setToolTip( "MAD and IQR test one feature at a time. Mahalanobis tests the " "features together and is the only one that can see a combination " "going wrong while each column on its own looks ordinary.") self.method.currentIndexChanged.connect(self._on_method_changed) form.addRow("Method", self.method) self.threshold = QDoubleSpinBox(panel) self.threshold.setObjectName("OutlierThreshold") self.threshold_label = QLabel("k (robust SDs)", panel) form.addRow(self.threshold_label, self.threshold) self.transform = QComboBox(panel) self.transform.setObjectName("OutlierTransform") self.transform.addItem("none — values as measured", TRANSFORM_NONE) self.transform.addItem("log10", TRANSFORM_LOG10) self.transform.setToolTip( "A symmetric fence on a right-skewed measurement flags a large " "share of the right tail whatever the data. log10 makes the rule " "mean what it looks like — and refuses on zero or negative " "values rather than inventing a pseudocount.") form.addRow("Transform", self.transform) self.per_well = Toggle("Score wells too", panel) self.per_well.setObjectName("OutlierPerWell") self.per_well.setChecked(True) self.per_well.setToolTip( "Reduce each well to the median of each feature and run the same " "rule across wells. A whole bad well flags almost none of its " "individual objects, so this is a different test rather than a " "summary of the one above.") form.addRow("", self.per_well) self.min_well = QSpinBox(panel) self.min_well.setObjectName("OutlierMinWellObjects") self.min_well.setRange(1, 100_000) self.min_well.setValue(DEFAULT_MIN_WELL_OBJECTS) self.min_well.setToolTip( "A well with fewer objects than this is reported 'not scored' " "rather than compared or dropped — a median over a handful of " "objects moves more than any plate effect worth finding.") form.addRow("Min objects / well", self.min_well) layout.addLayout(form) self.scan_button = QPushButton("Scan", panel) self.scan_button.setObjectName("PrimaryButton") self.scan_button.setToolTip("Run the test over the loaded table") self.scan_button.clicked.connect(self.scan) self.scan_button.setEnabled(False) layout.addWidget(self.scan_button) self._on_method_changed() return panel def _on_method_changed(self, *_args) -> None: """Re-point the single threshold spinbox at the chosen method's number. One control rather than three, because only one of ``k``, ``c`` and ``alpha`` is in play at a time and three greyed-out boxes teach nobody which one matters. """ label, tip, low, high, step, decimals, default = \ _THRESHOLD_FIELDS[self.current_method()] self.threshold_label.setText(label) self.threshold.setToolTip(tip) self.threshold.setDecimals(decimals) self.threshold.setRange(low, high) self.threshold.setSingleStep(step) self.threshold.setValue(default)
[docs] def current_method(self) -> str: """The :data:`spacr.qt.widgets.outlier_model.METHODS` member picked.""" return self.method.currentData() or METHOD_MAD
[docs] def spec(self) -> OutlierSpec: """The controls as an :class:`OutlierSpec`. The screen's whole state. :raises OutlierError: from the spec itself on a combination it refuses — the screen never invents a value the engine would not accept. """ method = self.current_method() value = float(self.threshold.value()) return OutlierSpec( features=self.features.selected(), method=method, k=value if method == METHOD_MAD else DEFAULT_MAD_K, c=value if method == METHOD_IQR else DEFAULT_IQR_C, alpha=value if method == METHOD_MAHALANOBIS else DEFAULT_ALPHA, transform=self.transform.currentData() or TRANSFORM_NONE, min_well_objects=int(self.min_well.value()), per_well=bool(self.per_well.isChecked()))
@property
[docs] def result(self): """The last :class:`OutlierResult`, or ``None``.""" return self._result
@property
[docs] def frame(self) -> Optional[pd.DataFrame]: """The table being scanned, unmodified.""" return self._frame
[docs] def objects_frame(self) -> Optional[pd.DataFrame]: """The loaded table with the flag columns added, or ``None``.""" return self._objects
[docs] def set_frame(self, frame: pd.DataFrame, *, label: str = "", scan: bool = True) -> None: """Scan ``frame``. The one call a host needs. :param frame: unmodified measurement table to expose in the feature picker and pass to the configured outlier scan. :param scan: ``False`` loads the table and fills the feature picker without running anything, for a caller that wants to set the method first. """ self._frame = frame self._result = None self._objects = None self._export.setEnabled(False) self.features.set_frame(frame) self.scan_button.setEnabled(True) self._source.setText( label or f"{len(frame):,} rows × {len(frame.columns)} columns") if scan: self.scan()
[docs] def choose_table(self) -> None: """Ask for a file, then :meth:`load_path` it.""" path, _ = QFileDialog.getOpenFileName( self, "Open a measurement table", "", "Measurements (*.db *.sqlite *.csv *.tsv);;All files (*)") if path: self.load_path(path)
[docs] def load_path(self, path: str, table: Optional[str] = None) -> None: """Load a CSV or one table of a SQLite measurement database. The read runs on a worker thread through :class:`spacr.qt.job_runner.JobRunner`; listing the table names stays inline because the picker has to be populated before the read is dispatched, to know which table to read. :param path: a CSV, TSV or TXT file (by extension), read as one table; any other path is opened as a SQLite measurement database and its tables are listed in the picker. :param table: the database table to read, also selected in the picker when the database has it; ``None`` reads the picker's current table. """ self._path = path names: List[str] = [] if not str(path).lower().endswith((".csv", ".tsv", ".txt")): try: names = table_names(path) except Exception as exc: LOG.info("could not list tables in %s", path, exc_info=True) self._report_failure( f"could not read {os.path.basename(path)}: {exc}") return self._table_picker.blockSignals(True) self._table_picker.clear() self._table_picker.addItems(names) self._table_picker.setVisible(bool(names)) if table and table in names: self._table_picker.setCurrentText(table) self._table_picker.blockSignals(False) chosen = table or (self._table_picker.currentText() or None) self._jobs.cancel() self._source.setText( f"loading {os.path.basename(path)}" + (f" · {chosen}" if chosen else "") + "…") self._jobs.submit( lambda p=path, t=chosen: (t, read_table(p, t)), self._on_frame_loaded)
def _on_frame_loaded(self, payload) -> None: """Hand a worker-read frame to the screen. GUI thread only.""" chosen, frame = payload path = self._path or "" suffix = f" · {chosen}" if chosen else "" self.set_frame( frame, label=f"{os.path.basename(path)}{suffix} · {len(frame):,} rows " f"× {len(frame.columns)} columns") def _on_table_picked(self, name: str) -> None: """Reload the current database at a newly chosen table. :param name: the table to read; a blank one, or no loaded path, does nothing. """ if self._path and name: self.load_path(self._path, table=name) def _report_failure(self, message: str) -> None: """Say it in the status line and tell anyone listening. Never a modal — a dialog nobody can dismiss is how a headless run hangs. The engine's refusals are written to be read by the user ("``cell_area`` has 412 non-positive values..."), so the message is shown verbatim rather than replaced with a house apology. """ LOG.info("outlier screen: %s", message) self._source.setText(message) self.failed.emit(message) def _on_job_failed(self, message: str) -> None: """A worker raised. Clear the result before saying so. Whatever is on screen was computed from a run that has now failed, so leaving the export button live would offer a file of stale flags under the name of the table that could not be read. """ self._result = None self._objects = None self._export.setEnabled(False) self.report.setPlainText(message) self._report_failure(message)
[docs] def scan(self) -> None: """Run the engine over the loaded table, off the GUI thread.""" frame = self._frame if frame is None: self._source.setText("Load a table first.") return try: spec = self.spec() except ValueError as exc: self._on_job_failed(str(exc)) return self._source.setText(f"scanning {len(frame):,} objects — " f"{spec.describe()}…") self._jobs.submit(lambda f=frame, s=spec: detect_outliers(f, s), self._on_scanned)
def _on_scanned(self, result) -> None: """Show a finished scan. GUI thread only.""" self._result = result frame = self._frame if frame is None: # pragma: no cover - the frame cannot vanish mid-job return self._objects = result.object_frame(frame) self._fill_object_table(result) self._fill_well_table(result) self.report.setPlainText(result.report()) self._export.setEnabled(True) self.tabs.setTabText( 0, f"Objects ({result.n_flagged:,})") self.tabs.setTabText( 1, f"Wells ({len(result.flagged_wells())})" if result.has_wells else "Wells") self._source.setText(result.headline()) self.scanned.emit(result) def _fill_object_table(self, result) -> None: """Worst rows first, capped, with the counts taken from the result.""" frame = self._objects if frame is None: # pragma: no cover - set immediately before return names = dict(result.column_names) columns = [c for c in result.well_keys if c in frame.columns] columns += [c for c in result.features if c in frame.columns] columns += [names[key] for key in ("outlier", "score", "reason") if names.get(key) in frame.columns] shown = frame.loc[:, columns] order = shown[names["score"]].sort_values( ascending=False, na_position="last").index _fill_table(self.object_table, shown.loc[order].head(MAX_TABLE_ROWS)) def _fill_well_table(self, result) -> None: """Every well, flagged first, then unscored, then the rest.""" wells = result.well_frame() if wells.empty: _fill_table(self.well_table, wells) return wells = wells.sort_values( ["well_outlier", "well_outlier_score"], ascending=[False, False], na_position="last") _fill_table(self.well_table, wells.head(MAX_TABLE_ROWS))
[docs] def active_jobs(self) -> int: """How many worker threads are still winding down.""" return self._jobs.active_jobs()
[docs] def is_busy(self) -> bool: """True while a read or a scan is in flight.""" return self._jobs.is_busy()
[docs] def export_csv(self) -> None: """Write the flags out: the whole table, the flagged rows, the wells. Three files, because they have three different row meanings and one sheet mixing them would have to be unpicked before anyone could use it. The first is the *whole* table with the columns added — the "write columns" case, and the one that keeps every object — and the second is the same rows filtered down to the flagged ones for a quick look. The engine's own ``filtered()`` is what produces it, so the file and the screen cannot disagree about what was flagged. """ result = self._result if result is None or self._objects is None: self._source.setText("Nothing to export — run a Scan first.") return path, _ = QFileDialog.getSaveFileName( self, "Export the outlier scan", "outliers.csv", "CSV (*.csv)") if not path: return stem = path[:-4] if path.lower().endswith(".csv") else path try: self._objects.to_csv(f"{stem}_objects.csv", index=False) flagged = self._objects.loc[result.flags] flagged.to_csv(f"{stem}_flagged.csv", index=False) if result.has_wells: result.well_frame().to_csv(f"{stem}_wells.csv", index=False) with open(f"{stem}_report.txt", "w", encoding="utf-8") as handle: handle.write(result.report() + "\n") except OSError as exc: LOG.info("could not export the outlier scan", exc_info=True) self._source.setText(f"could not write those files: {exc}") return self._source.setText( f"wrote {os.path.basename(stem)}_objects / _flagged" + (" / _wells" if result.has_wells else "") + " .csv and _report.txt")
[docs] def closeEvent(self, event): # noqa: N802 - Qt name """Stop background work and unlink before going away. :param event: the Qt close event. """ self._jobs.shutdown() super().closeEvent(event)
def _fill_table(table: QTableWidget, frame: pd.DataFrame) -> None: """Put ``frame`` into ``table`` as read-only text. Floats are formatted to six significant figures — a QTableWidget showing ``0.30000000000000004`` teaches nobody anything — and everything else is ``str``. Booleans render as ``yes`` / ``no``, because ``True`` in a column called ``outlier`` reads as a header rather than a value at a glance. """ table.clear() table.setRowCount(len(frame)) table.setColumnCount(len(frame.columns)) table.setHorizontalHeaderLabels([str(c) for c in frame.columns]) for column, name in enumerate(frame.columns): values = frame[name].tolist() for row, value in enumerate(values): table.setItem(row, column, table_item(_cell(value))) def _cell(value) -> str: """One frame value as the string a table cell should show.""" if isinstance(value, bool): return "yes" if value else "no" if isinstance(value, float): if value != value: return "" return f"{value:.6g}" return "" if value is None else str(value)
[docs] def make_outliers_screen(app_key: Optional[str] = None) -> QWidget: """Factory handed to :func:`spacr.qt.app.register_app`.""" return OutliersScreen()
_ROW = declared_app(APP_KEY) APP_NAME = _ROW.name APP_DESCRIPTION = _ROW.desc APP_INTRO = _ROW.intro APP_CLI_NOTE = _ROW.cli_note APP_NAME_TRANSLATIONS = _ROW.translations
[docs] def register() -> bool: """Put Outliers in the app registry, through the public seam. Idempotent. The row itself -- the key, the name, the blurb, the section, the "no headless run" sentence, the API doc link and the nine translations of the display name -- is declared in :mod:`spacr.qt.app_catalog`. :func:`spacr.qt.app.register_app` distributes those into the four tables each used to need a hand-edit in, and this function's whole job is to name which row. That is what lets the app be registered without importing this module at all: the launch reads the table, and the screen is imported when somebody opens it. ``SECTION_EXPLORE`` rather than ``SECTION_RESULTS``, and the reasoning is worth writing down because the first instinct is the other one. This screen looks like QC — it is about whether to believe what a run produced — but what it *does* is what the Gate Editor and the Feature Explorer do, and it sits beside them: you pick features, you move a threshold, you watch a distribution answer, and what comes out is a **column** the user then filters or gates on. Results & QC holds the screens that hand back a verdict; this one hands back a question with the evidence attached, and :meth:`~spacr.qt.widgets.outlier_model.OutlierResult.filtered` is the only place a row is ever dropped and it has to be called on purpose. The cap made the choice concrete rather than academic: ``MAX_APPS_PER_SECTION`` is 13 and Results & QC was already at 12 with Control Charts — the campaign-level verdict — arriving in the same batch. That is the section's honest occupant, and this one had somewhere honest to go. Both were checked against the cap before the placement, not after. **Not called at import.** ``app.py`` imports ``spacr.qt.widgets`` before ``register_app`` exists, so nothing reachable from the top of that file can register during its import, and a registration that happens later is one that some importer's snapshot of ``APPS`` predates. The one place a registration is visible to everybody is :data:`spacr.qt.SELF_REGISTERING_MODULES`, which :func:`spacr.qt.run` runs after ``spacr.qt.app`` is fully executed and before ``MainWindow.__init__`` reads the registry. Turning this screen on is therefore one row there:: "spacr.qt.screens.outliers", and nothing else: the strings above travel with the registration. :returns: ``True`` if this call is what registered it. Safe to call again — a module imported from two paths, or a test that re-imports it, must not raise on the duplicate key. """ return register_declared(__name__) is not None