This file documents conventions, non-obvious constraints, and known gotchas for this codebase. Read it before making any changes, especially to the UI layer.
uv sync --group dev # install runtime + dev dependencies
uv run pytest # all tests
uv run pytest -m "not integration" # unit tests only
uv run pytest -m integration # integration tests only
uv run pytest --cov=src/lsps --cov-report=term-missing # with coverage
uv run ruff format src/ tests/ # auto-format
uv run ruff check src/ tests/ # lint (report only)
uv run ruff check --fix src/ tests/ # lint + safe auto-fixes
uv run lsps # launch the GUIAlways run uv run pytest after making changes to verify nothing is broken.
- Python ≥ 3.11 —
match,tomllib,X | Yunion types,Selfare all available and used. - Ruff is the sole formatter and linter. Line length 99, double quotes.
Active rule sets:
E,W,F(pycodestyle/pyflakes),I(isort),UP(pyupgrade),B(bugbear),C4(comprehensions). Do not add a separateblackorflake8config. - Docstrings: every module starts with a module-level triple-quoted docstring.
Public classes and functions use NumPy-style (
Parameters,Returns,Raisessections). - Logging: every source module declares
logger = logging.getLogger(__name__)at module level. Usedebugfor per-call detail,infofor lifecycle events,warningfor recoverable failures,error(..., exc_info=True)for failures that lose data. - Typed dataclasses for all structured data (
@dataclass). No plaindictfor structured state.np.ndarrayfields default viafield(default_factory=lambda: np.array([])). pathlib.Patheverywhere — never bare strings for file paths.- Hardware is injected, never instantiated inside widgets or logic functions. Every
constructor that needs hardware accepts it as an argument and accepts
Noneas a valid no-op value (simulation mode).
These rules exist because violations have caused silent, hard-to-diagnose rendering bugs.
CameraWidget is a QWidget subclass. Do not change it to QLabel.
QLabel fills its entire background opaquely on every paint cycle. Because ScatterWidget is
a child of CameraWidget, an opaque parent repaint would overwrite all child pixels every 50 ms,
making the scatter overlay invisible after the first camera frame.
The scatter overlay is constructed with parent=self._camera_widget and positioned at (0, 0).
Do not use QStackedLayout to stack them as siblings.
Qt guarantees that child widgets always paint after their parent in the same paint cycle. With
siblings in a QStackedLayout, there is no such guarantee — an opaque sibling repaint can
overwrite the other sibling's pixels, and update() calls do not cascade between siblings.
Never write:
def paintEvent(self, event):
painter = QPainter(self)
if some_condition:
return # BUG: painter.end() never calledAn unreturned QPainter locks the widget's paint device. Qt blocks all subsequent paint events
on that widget — the widget goes permanently blank. Always use the context manager form so
painter.end() is guaranteed regardless of which code path is taken:
def paintEvent(self, event):
with QPainter(self) as painter:
if some_condition:
return # safe: context manager calls painter.end()Methods that access self._scatter_widget must not be called from _build_ui() before
self._scatter_widget is assigned. _refresh_scatter() has a broad except Exception handler
that silently swallows AttributeError and logs a WARNING — there is no crash to alert you.
The regression tests test_scatter_widget_populated_on_init and
test_init_does_not_log_scatter_warning guard this ordering.
tests/conftest.py sets os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") before any
PySide6 import. If you add a new conftest.py in a subdirectory that imports Qt, add the same
line at the very top before any PySide6 import.
- Serial port:
patch("lsps.hardware.arduino.serial.Serial", return_value=mock_serial) - Camera:
patch("cv2.VideoCapture", return_value=mock_video_capture) - Set
mock_serial.readline.return_value = b"1\n"as the default — this prevents thedigital_readpolling loop from blocking indefinitely. - Shared fixtures (
mock_serial,arduino,fake_frame,mock_video_capture,camera,default_params) live intests/conftest.py. Use them before creating new ones.
execute_run() accepts abf_loader as a callable argument so tests can pass
lambda p: {} directly without patching any module. If you move the import inside the function
body or make it a module-level constant, test isolation breaks.
Integration tests use FirmwareSimulator (a Python re-implementation of the Arduino state
machine) injected via the same serial mock pattern. All integration tests must carry:
pytestmark = pytest.mark.integrationRun them with uv run pytest -m integration. They are excluded from the default not integration
run to keep CI fast.
The application starts successfully with arduino=None and/or camera=None. This is intentional
and tested. CameraWidget skips frame capture when self._camera is None. ExperimentThread
passes None through to execute_run, which will fail at the first hardware call and emit
error_occurred — the expected behaviour when running without hardware.
- DAC pins 20 (X galvo) and 21 (Y galvo) are virtual pins with a special two-byte encoding:
the firmware reconstructs
val16 = hi * 256 + lofrom two successive bytes. The named constantsDAC_PIN_X = 20andDAC_PIN_Y = 21are exported fromarduino.py— use them instead of hardcoded integers. _DIGIDATA_TIMEOUT_S(5 s) can be patched in tests to avoid slow timeouts whendigital_readreturnsFalse.- DAC values out of range are clamped and logged as an error, not raised as an exception.
Scatter pixel positions are computed as:
pixels_per_step = grid_spacing / calibration.scale
calibration.scale is microns-per-pixel (default 3.123 µm/px). grid_spacing is in
microns. The result is pixels per grid step. Do not multiply — that inverts the units and
places the grid far outside the camera image bounds.