-
Notifications
You must be signed in to change notification settings - Fork 41
[MSD-39][feature] Adjust the Odemis FIBSEM milling tab to Tescan settings #3305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
39b6157
491b12e
1b2a215
ca66a55
caede8f
23d7bfd
47dea42
5660b02
2b0a655
e23cf81
d54c333
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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, | ||
|
|
@@ -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: | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Locate the file and check its existence
find . -name "fibsemos.py" -type fRepository: 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
fiRepository: delmic/odemis Length of output: 1866 Update docstrings to reST style with The helper functions ✏️ Example updatedef _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 |
||
|
|
||
| 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.""" | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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) | ||
|
|
@@ -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, {}) | ||
|
|
@@ -109,7 +164,7 @@ def create_fibsemos_microscope() -> Union['OdemisThermoMicroscope', 'OdemisTesca | |
| if stage_version == "tfs_3": | ||
| return create_fibsemos_tfs_microscope() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't the |
||
| 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") | ||
|
|
||
|
|
@@ -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) | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| # task converter | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cd src/odemis/acq/milling && wc -l fibsemos.pyRepository: delmic/odemis Length of output: 72 🏁 Script executed: cd src/odemis/acq/milling && sed -n '280,310p' fibsemos.pyRepository: delmic/odemis Length of output: 1322 🏁 Script executed: cd src/odemis/acq/milling && sed -n '380,410p' fibsemos.pyRepository: delmic/odemis Length of output: 784 🏁 Script executed: cd src/odemis/acq/milling && rg "_persistent_millmng" fibsemos.py -B 2 -A 2Repository: 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.pyRepository: delmic/odemis Length of output: 81
If 🛠️ 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 |
||
| 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 | ||
|
|
@@ -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() | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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:
🧰 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