Skip to content
Closed
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
27 changes: 27 additions & 0 deletions src/measureit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ensure_sweep_logging,
get_sweep_logger,
)
from .sweep.base_sweep import BaseSweep # noqa: F401
from .sweep.gate_leakage import GateLeakage # noqa: F401
from .sweep.simul_sweep import SimulSweep # noqa: F401
from .sweep.sweep0d import Sweep0D # noqa: F401
Expand Down Expand Up @@ -42,9 +43,35 @@
"ensure_sweep_logging",
"get_sweep_logger",
"attach_notebook_logging",
"get_all_sweeps",
"get_error_sweeps",
]


# Convenience functions for sweep registry
def get_all_sweeps():
"""Get all registered sweep instances.

Returns
-------
list
List of all sweep instances currently registered (not yet garbage collected).
"""
return BaseSweep.get_all_sweeps()


def get_error_sweeps():
"""Get all sweeps currently in ERROR state.

Returns
-------
list
List of sweep instances in ERROR state. These sweeps are held in memory
until explicitly killed or cleared to allow inspection.
"""
return BaseSweep.get_error_sweeps()


try:
__version__ = metadata.version("qmeasure")
except metadata.PackageNotFoundError: # pragma: no cover - dev installs
Expand Down
8 changes: 8 additions & 0 deletions src/measureit/_internal/plotter_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ def handle_close(self, event):
self.clear()
event.accept()

def clear_sweep_ref(self):
"""Break circular reference to sweep to allow garbage collection.

Called by sweep.kill() before setting plotter to None.
This helps break the reference cycle: Sweep ↔ Plotter.
"""
self.sweep = None

def key_pressed(self, event):
"""Handle keyboard shortcuts for sweep control.
Legacy method name for compatibility.
Expand Down
8 changes: 8 additions & 0 deletions src/measureit/_internal/runner_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ def add_plotter(self, plotter):
self.plotter = plotter
self.send_data.connect(self.plotter.add_data)

def clear_sweep_ref(self):
"""Break circular reference to sweep to allow garbage collection.

Called by sweep.kill() before setting runner to None.
This helps break the reference cycle: Sweep ↔ Runner.
"""
self.sweep = None

def _set_parent(self, sweep):
"""Sets a parent sweep if the Runner Thread is created independently.

Expand Down
72 changes: 72 additions & 0 deletions src/measureit/sweep/base_sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import time
import threading
import warnings
import weakref
from decimal import ROUND_HALF_EVEN, Decimal, localcontext
from functools import partial
from typing import Optional, Tuple
Expand Down Expand Up @@ -115,6 +116,12 @@ class BaseSweep(QObject):
Loads previously saved experimental setup.
"""

# Class-level sweep registry
_registry = weakref.WeakValueDictionary() # All sweeps (weak refs, allow GC)
_error_hold = set() # Strong refs for ERROR sweeps (prevents GC)
_next_id = 0 # Counter for unique sweep IDs
_registry_lock = threading.Lock() # Thread-safe registry access

update_signal = pyqtSignal(dict)
dataset_signal = pyqtSignal(dict)
reset_plot = pyqtSignal()
Expand Down Expand Up @@ -240,13 +247,61 @@ def __init__(
if suppress_output:
self.logger.debug("Sweep created with suppress_output=True")

# Register this sweep in the global registry
with BaseSweep._registry_lock:
self._sweep_id = BaseSweep._next_id
BaseSweep._next_id += 1
BaseSweep._registry[self._sweep_id] = self

@classmethod
def init_from_json(cls, fn, station):
"""Initializes QCoDeS station from previously saved setup."""
with open(fn) as json_file:
data = json.load(json_file)
return BaseSweep.import_json(data, station)

@classmethod
def get_all_sweeps(cls):
"""Get all registered sweep instances.

Returns
-------
list
List of all sweep instances currently registered (not yet garbage collected).
"""
with cls._registry_lock:
return list(cls._registry.values())

@classmethod
def get_error_sweeps(cls):
"""Get all sweeps currently in ERROR state.

Returns
-------
list
List of sweep instances in ERROR state. These sweeps are held in memory
until explicitly killed or cleared to allow inspection.

Notes
-----
This method returns sweeps from the error_hold set directly, which is more
efficient than filtering the full registry by state.
"""
with cls._registry_lock:
# Return directly from error_hold - these are all ERROR sweeps by definition
return list(cls._error_hold)

@classmethod
def _clear_registry_for_testing(cls):
"""Clear the sweep registry and error hold.

This method is intended for use in tests only to ensure a clean state
between test cases. It should not be used in production code.
"""
with cls._registry_lock:
cls._registry.clear()
cls._error_hold.clear()

def follow_param(self, *p):
"""Saves parameters to be tracked, for both saving and plotting data.

Expand Down Expand Up @@ -412,9 +467,16 @@ def kill(self):
if hasattr(self, "_error_completion_pending"):
self._error_completion_pending = False # Clear to prevent stale flag

# Release ERROR hold to allow garbage collection
with BaseSweep._registry_lock:
BaseSweep._error_hold.discard(self)

# Gently shut down the runner
runner = getattr(self, "runner", None)
if runner is not None:
# Break reference cycle before shutdown
if hasattr(runner, "clear_sweep_ref"):
runner.clear_sweep_ref()
# self.runner.quit()
if not runner.wait(1000):
runner.terminate()
Expand All @@ -424,6 +486,9 @@ def kill(self):
# Gently shut down the plotter
plotter = getattr(self, "plotter", None)
if plotter is not None:
# Break reference cycle before shutdown
if hasattr(plotter, "clear_sweep_ref"):
plotter.clear_sweep_ref()
# Backward-compatibility: if a plotter_thread exists from older runs, terminate it
try:
plotter_thread = getattr(self, "plotter_thread", None)
Expand Down Expand Up @@ -637,6 +702,10 @@ def mark_error(self, error_message: str, _from_runner: bool = False) -> None:
self.progressState.state = SweepState.ERROR
self.progressState.error_message = error_message

# Hold ERROR sweeps in memory to prevent garbage collection
with BaseSweep._registry_lock:
BaseSweep._error_hold.add(self)

# Propagate error to parent sweep (e.g., Sweep2D when inner Sweep1D fails)
parent = getattr(self, "parent", None)
if parent is not None and hasattr(parent, "mark_error"):
Expand Down Expand Up @@ -697,6 +766,9 @@ def clear_error(self) -> None:
self._error_completion_pending = False # Clear to prevent stale flag across runs
if self.progressState.state == SweepState.ERROR:
self.progressState.state = SweepState.READY
# Release ERROR hold to allow garbage collection
with BaseSweep._registry_lock:
BaseSweep._error_hold.discard(self)

def try_set(self, param, value) -> bool:
"""Set a parameter safely, transitioning to ERROR state on failure.
Expand Down
Loading