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
8 changes: 5 additions & 3 deletions src/odemis/acq/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ def __init__(self, name: str,
stage_position: Dict[str, float],
fm_focus_position: Dict[str, float],
streams: Optional[List[Stream]] = None,
milling_tasks: Optional[Dict[str, MillingTaskSettings]] = None, correlation_data=None):
milling_tasks: Optional[Dict[str, MillingTaskSettings]] = None,
correlation_data = None,
general_params = {}):
Comment on lines +152 to +154
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Mutable default argument will cause shared state bugs.

Using general_params = {} as a default creates a single dict object shared across all calls. If any code modifies this dict, it affects all subsequent instantiations.

 def __init__(self, name: str,
              stage_position: Dict[str, float],
              fm_focus_position: Dict[str, float],
              streams: Optional[List[Stream]] = None,
              milling_tasks: Optional[Dict[str, MillingTaskSettings]] = None,
-             correlation_data = None,
-             general_params = {}):
+             correlation_data = None,
+             general_params: Optional[Dict] = None):

Then initialize inside the method:

if general_params is None:
    general_params = {}
🧰 Tools
🪛 Ruff (0.14.8)

154-154: Do not use mutable data structures for argument defaults

Replace with None; initialize within function

(B006)

🤖 Prompt for AI Agents
In src/odemis/acq/feature.py around lines 152-154, the function signature uses a
mutable default argument `general_params = {}`, which leads to shared-state
bugs; change the default to `general_params=None` and inside the function check
`if general_params is None:` then assign a new empty dict (or a copy of a
provided dict) before using it, ensuring each call gets its own dictionary.

"""
:param name: (string) the feature name
:param stage_position: (dict) the stage position of the feature (stage-bare)
Expand All @@ -166,7 +168,7 @@ def __init__(self, name: str,
self.posture_positions: Dict[str, Dict[str, float]] = {} # positions for each posture

if milling_tasks is None:
milling_tasks = load_milling_tasks(DEFAULT_MILLING_TASKS_PATH)
milling_tasks = load_milling_tasks(DEFAULT_MILLING_TASKS_PATH, general_params)
self.milling_tasks: Dict[str, MillingTaskSettings] = milling_tasks

self.status = model.StringVA(FEATURE_ACTIVE)
Expand Down Expand Up @@ -353,7 +355,7 @@ def read_features(project_dir: str) -> List[CryoFeature]:
"""
Deserialize and return the features list from the json file
:param project_dir: directory to read the file from (typically project directory)
:return: list of deserialized featuers
:return: list of deserialized features
"""
filename = os.path.join(project_dir, "features.json")
if not os.path.exists(filename):
Expand Down
138 changes: 118 additions & 20 deletions src/odemis/acq/milling/fibsemos.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import threading
import time
from pathlib import Path
from concurrent import futures
from concurrent.futures._base import CANCELLED, FINISHED, RUNNING, CancelledError
from typing import List, Optional, Union
Expand All @@ -18,11 +19,16 @@
MillingSettings,
MillingTaskSettings,
)
from odemis.acq.feature import CryoFeature, REFERENCE_IMAGE_FILENAME
from odemis.util import executeAsyncTask

# Check if fibsemOS is available
try:
from fibsem.microscopes.odemis_microscope import OdemisThermoMicroscope, OdemisTescanMicroscope
from fibsem.microscopes.odemis_microscope import (
OdemisThermoMicroscope,
OdemisTescanMicroscope,
from_odemis_image
)
from fibsem.milling import (
FibsemMillingStage,
MillingAlignment,
Expand All @@ -35,7 +41,16 @@
RectanglePattern,
TrenchPattern,
)
from fibsem.structures import FibsemMillingSettings, Point
from fibsem.structures import (
FibsemMillingSettings,
Point,
FibsemImage,
FibsemImageMetadata,
FibsemRectangle,
BeamType,
ImageSettings,
MicroscopeState,
)
from fibsem.utils import load_microscope_configuration
FIBSEMOS_INSTALLED = True
except ImportError as e:
Expand All @@ -45,6 +60,47 @@
_persistent_millmng: Optional["FibsemOSMillingTaskManager"] = None


def _get_reference_image(feature: CryoFeature) -> model.DataArray:
"""Get the in-memory reference image for a feature or raise."""

if feature.reference_image is None:
logging.error(
"Missing reference image for feature '%s' (path=%s). "
"This feature was likely loaded from disk without hydrating reference_image.",
feature.name.value,
getattr(feature, "path", None),
)
raise ValueError("Missing feature.reference_image.")
return feature.reference_image

Comment on lines +63 to +75
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Locate the file and check its existence
find . -name "fibsemos.py" -type f

Repository: delmic/odemis

Length of output: 93


🏁 Script executed:

# Read the specific lines mentioned in the review
if [ -f "src/odemis/acq/milling/fibsemos.py" ]; then
  echo "=== Lines 60-80 (around _get_reference_image) ==="
  sed -n '60,80p' src/odemis/acq/milling/fibsemos.py
  echo ""
  echo "=== Lines 215-250 (around _format_preset) ==="
  sed -n '215,250p' src/odemis/acq/milling/fibsemos.py
fi

Repository: delmic/odemis

Length of output: 1866


Update docstrings to reST style with :param, :return, and :raises tags.

The helper functions _get_reference_image (line 63) and _format_preset (line 217) have docstrings that do not follow the reStructuredText style guide required by project guidelines.

✏️ Example update
def _get_reference_image(feature: CryoFeature) -> model.DataArray:
-    """Get the in-memory reference image for a feature or raise."""
+    """Get the in-memory reference image for a feature.
+
+    :param feature: Feature holding the reference image.
+    :return: In-memory reference image.
+    :raises ValueError: If reference_image is missing.
+    """

def _format_preset(voltage: float, current: float) -> str:
-    """
-    Format voltage (V) and current (A) into a string like:
-    '30 keV; 150 pA', scaling units automatically.
-    """
+    """Format voltage and current into a preset string.
+
+    :param voltage: Beam voltage in V.
+    :param current: Beam current in A.
+    :return: Formatted preset string (e.g., '30 keV; 150 pA').
+    """
🧰 Tools
🪛 Ruff (0.14.14)

[warning] 73-73: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
In `@src/odemis/acq/milling/fibsemos.py` around lines 63 - 75, Update the
docstrings for the helper functions _get_reference_image and _format_preset to
reStructuredText style by adding :param, :return, and :raises entries;
specifically for _get_reference_image include ":param feature: CryoFeature the
feature to read the reference image from", ":return: model.DataArray the
in-memory reference image", and ":raises ValueError: if feature.reference_image
is None", and similarly document _format_preset’s parameters, return value and
any exceptions it can raise (use the actual parameter names and types from the
function signature so the docstrings match the code).


def _crop_to_reduced_area(ref_img: 'FibsemImage', rect: 'FibsemRectangle') -> 'FibsemImage':
"""Crop a fibsemOS image to the provided reduced-area rectangle.

:param ref_img: The image to crop.
:param rect: Rectangle with fractional coordinates (left, top, width, height).
:return: The same image instance with cropped data.
"""

h, w = ref_img.data.shape[-2], ref_img.data.shape[-1]

# fractional to pixel indices
x0 = int(rect.left * w)
y0 = int(rect.top * h)
x1 = int((rect.left + rect.width) * w)
y1 = int((rect.top + rect.height) * h)

# clamp to valid range just in case of rounding
x0 = max(0, min(w, x0))
x1 = max(0, min(w, x1))
y0 = max(0, min(h, y0))
y1 = max(0, min(h, y1))

# crop along the last two axes, DataArray slicing behaves like numpy
ref_img.data = ref_img.data[..., y0:y1, x0:x1]
return ref_img


def create_fibsemos_tfs_microscope() -> 'OdemisThermoMicroscope':
"""Create and return a fibsemOS Thermo microscope instance."""

Expand All @@ -67,8 +123,8 @@ def create_fibsemos_tfs_microscope() -> 'OdemisThermoMicroscope':

return microscope

def create_fibsemos_tescan_microscope() -> 'OdemisTescanMicroscope':
"""Create, connect, and return a fibsemOS Tescan microscope instance."""
def create_fibsemos_tescan_microscope(config_path: Path = None) -> 'OdemisTescanMicroscope':
"""Create a fibsemOS Tescan microscope instance with the current microscope configuration."""

# TODO: Extract the rest of the required metadata

Expand All @@ -79,7 +135,7 @@ def create_fibsemos_tescan_microscope() -> 'OdemisTescanMicroscope':
rotation_reference = stage_md[model.MD_FAV_SEM_POS_ACTIVE]["rz"]

# loads the default config
config = load_microscope_configuration()
config = load_microscope_configuration(config_path)
config.system.stage.shuttle_pre_tilt = math.degrees(pre_tilt)
# Used by fibsemOS for moving the stage flat to the electron beam
config.system.stage.rotation_reference = math.degrees(rotation_reference)
Expand All @@ -98,9 +154,8 @@ def create_fibsemos_tescan_microscope() -> 'OdemisTescanMicroscope':

return microscope

def create_fibsemos_microscope() -> Union['OdemisThermoMicroscope', 'OdemisTescanMicroscope']:
"""Create and return a fibsemOS microscope instance matching the detected stage version.
"""
def create_fibsemos_microscope(config_path: Path = None) -> Union['OdemisThermoMicroscope | OdemisTescanMicroscope']:
"""Create a fibsemOS microscope instance with the current microscope configuration."""
stage_bare = model.getComponent(role="stage-bare")
stage_md = stage_bare.getMetadata()
md_calib = stage_md.get(model.MD_CALIB, {})
Expand All @@ -109,7 +164,7 @@ def create_fibsemos_microscope() -> Union['OdemisThermoMicroscope', 'OdemisTesca
if stage_version == "tfs_3":
return create_fibsemos_tfs_microscope()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the tfs version do the same? At least, it would feel more logical that it's also possible to override the default path.

elif stage_version == "tescan_1":
return create_fibsemos_tescan_microscope()
return create_fibsemos_tescan_microscope(config_path)
else:
raise ValueError(f"Stage version {stage_version} is not supported")

Expand Down Expand Up @@ -159,13 +214,47 @@ def _convert_microexpansion_pattern(p: MicroexpansionPatternParameters) -> 'Micr
point=Point(x=p.center.value[0], y=p.center.value[1])
)

def _format_preset(voltage: float, current: float) -> str:
"""
Format voltage (V) and current (A) into a string like:
'30 keV; 150 pA', scaling units automatically.
"""

# Voltage: always shown in keV
voltage_keV = voltage / 1000
voltage_str = f"{voltage_keV:g} keV"

# Current: choose pA, nA, or uA
abs_I = abs(current)

if abs_I < 1e-9:
# pA
current_val = current * 1e12
unit = "pA"
elif abs_I < 1e-6:
# nA
current_val = current * 1e9
unit = "nA"
else:
# uA
current_val = current * 1e6
unit = "uA"

current_str = f"{current_val:g} {unit}"

return f"{voltage_str}; {current_str}"


def convert_milling_settings(s: MillingSettings) -> 'FibsemMillingSettings':
"""Convert Odemis milling settings to fibsemOS milling settings."""
return FibsemMillingSettings(
milling_current=s.current.value,
milling_voltage=s.voltage.value,
patterning_mode=s.mode.value,
hfw=s.field_of_view.value,
rate=s.rate.value, # m^3/A/s
dwell_time=s.dwell_time.value, # s
preset=_format_preset(s.voltage.value, s.current.value)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# task converter
Expand Down Expand Up @@ -196,18 +285,14 @@ def convert_milling_tasks_to_milling_stages(milling_tasks: List[MillingTaskSetti
class FibsemOSMillingTaskManager:
"""Manage running milling tasks via fibsemOS using a persistent microscope connection."""

def __init__(self, path: Optional[str] = None):
def __init__(self, config_path: Optional[str] = None):
"""Initialize the manager and establish the fibsemOS microscope connection."""
# create microscope connection
self.microscope = create_fibsemos_microscope()
self.microscope = create_fibsemos_microscope(config_path)
self._lock = threading.Lock()
self._active = False
self._cancel_requested = False

Comment on lines +288 to 295
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd src/odemis/acq/milling && wc -l fibsemos.py

Repository: delmic/odemis

Length of output: 72


🏁 Script executed:

cd src/odemis/acq/milling && sed -n '280,310p' fibsemos.py

Repository: delmic/odemis

Length of output: 1322


🏁 Script executed:

cd src/odemis/acq/milling && sed -n '380,410p' fibsemos.py

Repository: delmic/odemis

Length of output: 784


🏁 Script executed:

cd src/odemis/acq/milling && rg "_persistent_millmng" fibsemos.py -B 2 -A 2

Repository: delmic/odemis

Length of output: 694


🏁 Script executed:

cd src/odemis/acq/milling && head -50 fibsemos.py | grep -E "^import|^from"

Repository: delmic/odemis

Length of output: 517


🏁 Script executed:

cd src/odemis/acq/milling && rg "from pathlib|import Path" fibsemos.py

Repository: delmic/odemis

Length of output: 81


config_path changes are ignored after the first manager creation.

If config_path can vary per run, the persistent manager will keep using the first path. Store the path on the manager and recreate when it differs.

🛠️ One way to guard against stale config_path
-    def __init__(self, config_path: Optional[str] = None):
+    def __init__(self, config_path: Path | None = None):
         """Initialize the manager and establish the fibsemOS microscope connection."""
+        self.config_path = config_path
         # create microscope connection
         self.microscope = create_fibsemos_microscope(config_path)
@@
-def run_milling_tasks_fibsemos(tasks: List[MillingTaskSettings], feature: CryoFeature, path: Optional[str] = None, config_path: Optional[str] = None) -> futures.Future:
+def run_milling_tasks_fibsemos(tasks: List[MillingTaskSettings], feature: CryoFeature, path: Optional[str] = None, config_path: Path | None = None) -> futures.Future:
@@
-    if _persistent_millmng is None:
-        _persistent_millmng = FibsemOSMillingTaskManager(config_path)
+    if (
+        _persistent_millmng is None
+        or (config_path is not None and _persistent_millmng.config_path != config_path)
+    ):
+        _persistent_millmng = FibsemOSMillingTaskManager(config_path)
🤖 Prompt for AI Agents
In `@src/odemis/acq/milling/fibsemos.py` around lines 288 - 295, The manager
currently always calls create_fibsemos_microscope(config_path) only once and
ignores later differing config_path values; to fix, store the provided
config_path on the instance (e.g., self._config_path) in __init__ and update any
code paths that obtain the microscope (or add a helper like
_ensure_microscope(config_path)) to compare the incoming config_path against
self._config_path and recreate self.microscope via
create_fibsemos_microscope(config_path) when they differ, ensuring you also
update self._config_path and preserve thread-safety using the existing
self._lock around recreation.

if path is None:
path = os.getcwd()
self.microscope._last_imaging_settings.path = path # note: post-milling imaging is not yet supported via odemis

# per-run state (set in async_run)
self.milling_stages: List["FibsemMillingStage"] = []
self._future: Optional[futures.Future] = None
Expand Down Expand Up @@ -251,14 +336,25 @@ def _run(self):
raise CancelledError()

logging.info(f"Running milling stage: {stage.name}")
mill_stages(self.microscope, [stage])
ref_img = from_odemis_image(_get_reference_image(self.feature))
ref_img.metadata.image_settings.path = self.path
ref_img.metadata.image_settings.reduced_area = stage.alignment.rect

ref_img = _crop_to_reduced_area(ref_img, stage.alignment.rect)

mill_stages(self.microscope, [stage], ref_img)

finally:
with self._lock:
self._active = False
self._cancel_requested = False

def async_run(self, *, future: futures.Future, tasks: List[MillingTaskSettings], path: Optional[str] = None) -> futures.Future:
def async_run(self,
*,
future: futures.Future,
tasks: List[MillingTaskSettings],
feature: CryoFeature,
path: Optional[str] = None) -> futures.Future:
"""Prepare and start a milling run asynchronously (one run at a time)."""
if path is None:
path = os.getcwd()
Expand All @@ -273,6 +369,8 @@ def async_run(self, *, future: futures.Future, tasks: List[MillingTaskSettings],
self._cancel_requested = False
self.microscope._last_imaging_settings.path = path
self.milling_stages = milling_stages
self.path = path
self.feature = feature
self._future = future
self._future.running_subf = model.InstantaneousFuture()
self._future.task_canceller = self.cancel
Expand All @@ -287,12 +385,12 @@ def async_run(self, *, future: futures.Future, tasks: List[MillingTaskSettings],
return self._future


def run_milling_tasks_fibsemos(tasks: List[MillingTaskSettings], path: Optional[str] = None) -> futures.Future:
def run_milling_tasks_fibsemos(tasks: List[MillingTaskSettings], feature: CryoFeature, path: Optional[str] = None, config_path: Optional[str] = None) -> futures.Future:
"""Run the given milling tasks asynchronously using a persistent fibsemOS manager."""
global _persistent_millmng

if _persistent_millmng is None:
_persistent_millmng = FibsemOSMillingTaskManager()
_persistent_millmng = FibsemOSMillingTaskManager(config_path)

future = model.ProgressiveFuture()
return _persistent_millmng.async_run(future=future, tasks=tasks, path=path)
return _persistent_millmng.async_run(future=future, tasks=tasks, feature=feature, path=path)
14 changes: 8 additions & 6 deletions src/odemis/acq/milling/millmng.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import os
import threading
import time
from pathlib import Path
from concurrent.futures import Future
from concurrent.futures._base import CANCELLED, FINISHED, RUNNING, CancelledError
from datetime import datetime
Expand Down Expand Up @@ -245,7 +246,7 @@ def run(self):
self._future._task_state = FINISHED


# TODO: replace with run_milling_tasks_fibsemos
# NOTE: replaced with run_milling_tasks_fibsemos
def run_milling_tasks(tasks: List[MillingTaskSettings], fib_stream: FIBStream, filename: str = None) -> Future:
"""
Run multiple milling tasks in order.
Expand Down Expand Up @@ -302,6 +303,7 @@ def __init__(self,
sem_stream: SEMStream,
fib_stream: FIBStream,
task_list: List[MillingWorkflowTask],
config_path: Path=None
):

self.stage = stage
Expand All @@ -313,6 +315,7 @@ def __init__(self,
self._exporter = find_fittest_converter("filename.ome.tiff")
self.pm = MicroscopePostureManager(model.getMicroscope())
self._prefix: str = ""
self.config_path = config_path

self._future = future
if future is not None:
Expand Down Expand Up @@ -389,9 +392,6 @@ def run(self):
############# STAGE MOVEMENT #############
self._move_to_milling_position(feature)

############# ALIGNMENT #############
self._align_reference_image(feature)

############# MILLING #############
self._run_milling_tasks(feature, milling_tasks)

Expand Down Expand Up @@ -434,10 +434,10 @@ def _run_milling_tasks(self, feature: CryoFeature, milling_tasks: List[MillingTa
self._future.msg = f"{feature.name.value}: Milling: {self.current_workflow}"
self._future.set_progress()

filename = self.get_filename(feature, "Milling-Tasks")
if fibsemos.FIBSEMOS_INSTALLED:
self._future.running_subf = run_milling_tasks_fibsemos(tasks=milling_tasks)
self._future.running_subf = run_milling_tasks_fibsemos(tasks=milling_tasks, feature=feature, path=feature.path, config_path=self.config_path)
else:
filename = self.get_filename(feature, "Milling-Tasks")
self._future.running_subf = run_milling_tasks(tasks=milling_tasks,
fib_stream=self.fib_stream,
filename=filename)
Expand Down Expand Up @@ -516,6 +516,7 @@ def run_automated_milling(features: List[CryoFeature],
sem_stream: SEMStream,
fib_stream: FIBStream,
task_list: List[MillingWorkflowTask],
config_path: Path=None
) -> Future:
"""
Automatically mill and image a list of features.
Expand All @@ -532,6 +533,7 @@ def run_automated_milling(features: List[CryoFeature],
fib_stream=fib_stream,
task_list=task_list,
features=features,
config_path=config_path
)
# add the ability of cancelling the future during execution
future.task_canceller = amm.cancel
Expand Down
Loading