Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions software/Python 3.14 Compatibility Check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Python 3.14 Compatibility -- Squid Dependencies

## Supported (confirmed)
| Package | Notes |
|---------|-------|
| numpy | Wheels for 3.14 since v2.3+ |
| scipy | Supported |
| pandas | Supported |
| pydantic (v2) | Supported -- but **pydantic v1** (used by old napari) is **NOT** |
| Pillow | Supported |
| matplotlib | Supported |
| PyYAML | Supported |
| filelock | Supported |
| platformdirs | Supported |
| scikit-image | Wheels for 3.14 since v0.26 |
| napari (latest) | Supported -- requires PyQt6, not PyQt5 |
| opencv-python-headless | Uses stable ABI (cp37-abi3), works |
| opencv-contrib-python-headless | Same stable ABI, works |
| torch (PyTorch) | Supported since ~2.13 |
| dask | Active releases in 2026 |
| zarr | Supported |
| tifffile | Supported |
| imageio | Supported |
| pyqtgraph | Pure Python, likely works |
| qtpy | Pure Python abstraction layer, works |

## Blocked / Incompatible
| Package | Issue |
|---------|-------|
| **PyQt5** | **No Python 3.14 support -- this is the main blocker** |
| **pydantic v1** | Explicitly incompatible with 3.14 (the `__slots__` error) |

## Uncertain / Likely OK but unconfirmed
| Package | Notes |
|---------|-------|
| pyserial | Pure Python, last release 2020 -- probably works but untested |
| pyvisa | Likely works |
| hidapi | Cython extension -- no 3.14 wheels confirmed |
| GitPython | Marked as NOT supporting 3.14 on pyreadiness.org |
| aicsimageio | Unclear, niche package |
| basicpy | Unclear |
| ome-zarr | Unclear |
| pydantic-xml | Likely works if pydantic v2 is used |
| pyreadline3 | Windows-specific, unclear |
| qtconsole | Depends on Qt backend |
| lxml / lxml_html_clean | C extension -- needs checking |
| mcp | Unclear |
| Hardware drivers (PySpin, pyAndorSDK3, ids_peak, pyvcam, pm16) | Vendor-specific, unlikely to have 3.14 builds yet |

## Bottom Line

The **two hard blockers** are **PyQt5** and **pydantic v1** (via old napari). Migrating to Python 3.14 would require:
1. Switching from PyQt5 to PyQt6 (non-trivial API changes)
2. Upgrading napari to a version that uses pydantic v2
3. Verifying all hardware vendor SDKs have 3.14 builds

**Recommendation: Stay on Python 3.10** for production use. It's the sweet spot where everything works.

## Sources
- [Python 3.14 Readiness](https://pyreadiness.org/3.14/)
- [NumPy 2.3.0 Release Notes](https://numpy.org/devdocs/release/2.3.0-notes.html)
- [PyQt5 on PyPI](https://pypi.org/project/PyQt5/)
- [napari Installation](https://napari.org/stable/getting_started/installation.html)
- [PyTorch torch.compile Python 3.14 support](https://dev-discuss.pytorch.org/t/torch-compile-support-for-python-3-14-completed/3276)
- [Pydantic v1 Python 3.14 issue](https://github.com/pydantic/pydantic/issues/12618)
- [Anaconda Python 3.14 overview](https://www.anaconda.com/blog/python-3-14-what-data-scientists-developers-need-know)
2 changes: 1 addition & 1 deletion software/control/console.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os

# set QT_API environment variable
os.environ["QT_API"] = "pyqt5"
os.environ["QT_API"] = "pyqt6"

import qtpy
from qtpy.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QPushButton, QLabel
Expand Down
5 changes: 1 addition & 4 deletions software/control/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import tempfile

# qt libraries
os.environ["QT_API"] = "pyqt5"
os.environ["QT_API"] = "pyqt6"
import qtpy
import pyqtgraph as pg
from qtpy.QtCore import *
Expand Down Expand Up @@ -1677,9 +1677,6 @@ def update_wellplate_settings(
rows,
cols,
):
if isinstance(sample_format, QVariant):
sample_format = sample_format.value()

if sample_format == "glass slide":
if IS_HCS:
sample = "4 glass slide"
Expand Down
2 changes: 1 addition & 1 deletion software/control/core_PDAF.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# set QT_API environment variable
import os

os.environ["QT_API"] = "pyqt5"
os.environ["QT_API"] = "pyqt6"

# qt libraries
from qtpy.QtCore import *
Expand Down
2 changes: 1 addition & 1 deletion software/control/core_displacement_measurement.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# set QT_API environment variable
import os

os.environ["QT_API"] = "pyqt5"
os.environ["QT_API"] = "pyqt6"
import qtpy

# qt libraries
Expand Down
2 changes: 1 addition & 1 deletion software/control/core_usbspectrometer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# set QT_API environment variable
import os

os.environ["QT_API"] = "pyqt5"
os.environ["QT_API"] = "pyqt6"
import qtpy

# qt libraries
Expand Down
6 changes: 3 additions & 3 deletions software/control/core_volumetric_imaging.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# set QT_API environment variable
import os

os.environ["QT_API"] = "pyqt5"
os.environ["QT_API"] = "pyqt6"
import qtpy

# qt libraries
Expand Down Expand Up @@ -173,8 +173,8 @@ def __init__(self, window_title=""):
self.setCentralWidget(self.widget)

# set window size
desktopWidget = QDesktopWidget()
width = min(desktopWidget.height() * 0.9, 1000) # @@@TO MOVE@@@#
screen_height = QApplication.primaryScreen().size().height()
width = int(min(screen_height * 0.9, 1000)) # @@@TO MOVE@@@#
height = width
self.setFixedSize(width, height)

Expand Down
77 changes: 49 additions & 28 deletions software/control/gui_hcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
ClearedScanCoordinates,
)

os.environ["QT_API"] = "pyqt5"
os.environ["QT_API"] = "pyqt6"
import re
import time
from enum import Enum, auto
Expand Down Expand Up @@ -1426,8 +1426,7 @@ def _getMainWindowMinimumSize(self):
We want our main window to fit on the primary screen, so grab the users primary screen and return
something slightly smaller than that.
"""
desktop_info = QDesktopWidget()
primary_screen_size = desktop_info.screen(desktop_info.primaryScreen()).size()
primary_screen_size = QApplication.primaryScreen().size()

height_min = int(0.9 * primary_screen_size.height())
width_min = int(0.96 * primary_screen_size.width())
Expand Down Expand Up @@ -1819,33 +1818,42 @@ def makeNapariConnections(self):
self.updateNapariConnections()

def updateNapariConnections(self):
# Update Napari connections based on performance mode. Live widget connections are preserved
# Update Napari connections based on performance mode.
# Connection tuples can be:
# (signal, slot) - uses default Qt.AutoConnection
# (signal, slot, connection_type) - uses specified connection type (e.g., Qt.QueuedConnection)
#
# The live widget is always kept connected. In performance mode the mosaic's
# data feed is ALSO kept connected: its canvas still builds during acquisition,
# but rendering is deferred while its tab is hidden and flushed once when the run
# finishes (see UnifiedMosaicWidget.showEvent and toggleAcquisitionStart). Only
# the remaining napari views (e.g. multichannel) are disconnected in performance mode.
for widget_name, connections in self.napari_connections.items():
if widget_name != "napariLiveWidget": # Always keep the live widget connected
widget = getattr(self, widget_name, None)
if widget:
for conn in connections:
signal = conn[0]
slot = conn[1]
connection_type = conn[2] if len(conn) > 2 else None
if self.performance_mode:
try:
signal.disconnect(slot)
except TypeError:
# Connection might not exist, which is fine
pass
if widget_name == "napariLiveWidget": # always kept connected (wired at init)
continue
keep_connected = (not self.performance_mode) or widget_name == "unifiedMosaicWidget"
widget = getattr(self, widget_name, None)
if not widget:
continue
for conn in connections:
signal = conn[0]
slot = conn[1]
connection_type = conn[2] if len(conn) > 2 else None
if keep_connected:
try:
if connection_type is not None:
signal.connect(slot, connection_type)
else:
try:
if connection_type is not None:
signal.connect(slot, connection_type)
else:
signal.connect(slot)
except TypeError:
# Connection might already exist, which is fine
pass
signal.connect(slot)
Comment on lines +1843 to +1847
except TypeError:
# Connection might already exist, which is fine
pass
else:
try:
signal.disconnect(slot)
except TypeError:
# Connection might not exist, which is fine
pass

def toggleNapariTabs(self):
# Enable/disable Napari tabs based on performance mode
Expand Down Expand Up @@ -2386,9 +2394,6 @@ def onDisplayTabChanged(self, index):
self.toggleWellSelector(False)

def onWellplateChanged(self, format_):
if isinstance(format_, QVariant):
format_ = format_.value()

# TODO(imo): Not sure why glass slide is so special here? It seems like it's just a "1 well plate".
if format_ == "glass slide":
self.toggleWellSelector(False)
Expand Down Expand Up @@ -2501,13 +2506,29 @@ def toggleAcquisitionStart(self, acquisition_started):
self.live_scan_grid_was_on = False
# NOTE: RAM monitor widget is connected via multipointController.signal_acquisition_start
# which fires AFTER the memory monitor is created (see make_connections)

# Performance mode: keep the mosaic/multichannel napari tabs hidden and
# disabled for the duration of the run so the mosaic canvas builds without
# rendering (deferred), avoiding the per-tile GL stalls. A previous run's
# completion may have re-enabled/shown them, so re-apply the hidden state here.
if self.performance_mode:
self.toggleNapariTabs()
else:
self.log.info("FINISHED ACQUISITION")
if self.live_scan_grid_was_on:
self.toggle_live_scan_grid(on=True)
self.live_scan_grid_was_on = False
# NOTE: RAM monitor widget is disconnected via multipointController.acquisition_finished

# Performance mode: render the assembled mosaic once, now that the run is
# done. Re-enable the napari tabs and switch to the mosaic view; becoming
# visible flushes the single deferred refresh (UnifiedMosaicWidget.showEvent).
if self.performance_mode:
for i in range(self.imageDisplayTabs.count()):
self.imageDisplayTabs.setTabEnabled(i, True)
if self.unifiedMosaicWidget is not None:
self.imageDisplayTabs.setCurrentWidget(self.unifiedMosaicWidget)

# click to move off during acquisition
self.navigationWidget.set_click_to_move(not acquisition_started)

Expand Down
2 changes: 1 addition & 1 deletion software/control/single_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

# Pin the Qt binding before importing qtpy, matching the convention used by
# every other Qt-using module in this codebase.
os.environ["QT_API"] = "pyqt5"
os.environ["QT_API"] = "pyqt6"

import getpass
from typing import NamedTuple, Optional
Expand Down
4 changes: 2 additions & 2 deletions software/control/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from squid.config import CameraPixelFormat

# set QT_API environment variable
os.environ["QT_API"] = "pyqt5"
os.environ["QT_API"] = "pyqt6"

# qt libraries
import qtpy
Expand Down Expand Up @@ -4170,7 +4170,7 @@ def add_components(self, show_trigger_options, show_display_options, show_autole
self.entry_displayFPS.valueChanged.connect(self.streamHandler.set_display_fps)
self.slider_resolutionScaling.valueChanged.connect(self.streamHandler.set_display_resolution_scaling)
self.slider_resolutionScaling.valueChanged.connect(self.liveController.set_display_resolution_scaling)
self.dropdown_modeSelection.activated[str].connect(self.select_new_microscope_mode_by_name)
self.dropdown_modeSelection.textActivated.connect(self.select_new_microscope_mode_by_name)
self.dropdown_triggerManu.currentIndexChanged.connect(self.update_trigger_mode)
self.btn_live.clicked.connect(self.toggle_live)
self.entry_exposureTime.valueChanged.connect(self.update_config_exposure_time)
Expand Down
Loading
Loading