Skip to content

Latest commit

 

History

History
166 lines (120 loc) · 6.94 KB

File metadata and controls

166 lines (120 loc) · 6.94 KB

AGENTS.md — Guidance for AI Agents

This file documents conventions, non-obvious constraints, and known gotchas for this codebase. Read it before making any changes, especially to the UI layer.


Commands

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 GUI

Always run uv run pytest after making changes to verify nothing is broken.


Code Conventions

  • Python ≥ 3.11match, tomllib, X | Y union types, Self are 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 separate black or flake8 config.
  • Docstrings: every module starts with a module-level triple-quoted docstring. Public classes and functions use NumPy-style (Parameters, Returns, Raises sections).
  • Logging: every source module declares logger = logging.getLogger(__name__) at module level. Use debug for per-call detail, info for lifecycle events, warning for recoverable failures, error(..., exc_info=True) for failures that lose data.
  • Typed dataclasses for all structured data (@dataclass). No plain dict for structured state. np.ndarray fields default via field(default_factory=lambda: np.array([])).
  • pathlib.Path everywhere — 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 None as a valid no-op value (simulation mode).

Qt Widget Rules

These rules exist because violations have caused silent, hard-to-diagnose rendering bugs.

Use QWidget, not QLabel, for the camera feed

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.

ScatterWidget must be a child of CameraWidget, not a sibling

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.

Always use with QPainter(self) as painter: in paintEvent

Never write:

def paintEvent(self, event):
    painter = QPainter(self)
    if some_condition:
        return          # BUG: painter.end() never called

An 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()

MainWindow._build_ui() init ordering

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.


Testing Patterns

Qt tests require QT_QPA_PLATFORM=offscreen

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.

Hardware mocking

  • 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 the digital_read polling loop from blocking indefinitely.
  • Shared fixtures (mock_serial, arduino, fake_frame, mock_video_capture, camera, default_params) live in tests/conftest.py. Use them before creating new ones.

abf_loader is injected — do not refactor it to a module-level import

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

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.integration

Run them with uv run pytest -m integration. They are excluded from the default not integration run to keep CI fast.


Simulation Mode

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.


Hardware Protocol Notes

  • DAC pins 20 (X galvo) and 21 (Y galvo) are virtual pins with a special two-byte encoding: the firmware reconstructs val16 = hi * 256 + lo from two successive bytes. The named constants DAC_PIN_X = 20 and DAC_PIN_Y = 21 are exported from arduino.py — use them instead of hardcoded integers.
  • _DIGIDATA_TIMEOUT_S (5 s) can be patched in tests to avoid slow timeouts when digital_read returns False.
  • DAC values out of range are clamped and logged as an error, not raised as an exception.

Coordinate System

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.