Skip to content

Commit 5781360

Browse files
authored
Added logic to FIBContext for Analyser to register FIB atlas images and associated metadata (#758)
* Added logic to trigger FIB context and optimised logic for triggering CLEM context * Added logic to parse FIB Maps metadata and images and trigger workflow (to be determine) once both are accounted for * Added logic to determine destination path for files in FIB workflow
1 parent 0447842 commit 5781360

4 files changed

Lines changed: 638 additions & 15 deletions

File tree

src/murfey/client/analyser.py

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from murfey.client.context import Context
1818
from murfey.client.contexts.atlas import AtlasContext
1919
from murfey.client.contexts.clem import CLEMContext
20+
from murfey.client.contexts.fib import FIBContext
2021
from murfey.client.contexts.spa import SPAModularContext
2122
from murfey.client.contexts.spa_metadata import SPAMetadataContext
2223
from murfey.client.contexts.tomo import TomographyContext
@@ -125,20 +126,62 @@ def _find_context(self, file_path: Path) -> bool:
125126
"""
126127
logger.debug(f"Finding context using file {str(file_path)!r}")
127128

129+
# -----------------------------------------------------------------------------
128130
# CLEM workflow checks
129-
# Look for LIF and XLIF files
130-
if file_path.suffix in (".lif", ".xlif"):
131+
# -----------------------------------------------------------------------------
132+
if (
133+
# Look for LIF and XLIF files
134+
file_path.suffix in (".lif", ".xlif")
135+
or (
136+
# TIFF files have "--Stage", "--Z", and/or "--C" in their file stem
137+
file_path.suffix in (".tiff", ".tif")
138+
and any(
139+
pattern in file_path.stem for pattern in ("--Stage", "--Z", "--C")
140+
)
141+
)
142+
):
131143
self._context = CLEMContext("leica", self._basepath, self._token)
132144
return True
133-
# Look for TIFF files associated with CLEM workflow
134-
# CLEM TIFF files will have "--Stage", "--Z", and/or "--C" in their file stem
135-
if any(
136-
pattern in file_path.stem for pattern in ("--Stage", "--Z", "--C")
137-
) and file_path.suffix in (".tiff", ".tif"):
138-
self._context = CLEMContext("leica", self._basepath, self._token)
145+
146+
# -----------------------------------------------------------------------------
147+
# FIB workflow checks
148+
# -----------------------------------------------------------------------------
149+
# Determine if it's from AutoTEM
150+
if (
151+
# AutoTEM generates a "ProjectData.dat" file
152+
file_path.name == "ProjectData.dat"
153+
or (
154+
# Images are stored in ".../Sites/Lamella (N)/..."
155+
any(path.startswith("Lamella") for path in file_path.parts)
156+
and "Sites" in file_path.parts
157+
)
158+
):
159+
self._context = FIBContext("autotem", self._basepath, self._token)
160+
return True
161+
162+
# Determine if it's from Maps
163+
if (
164+
# Electron snapshot metadata in "EMproject.emxml"
165+
file_path.name == "EMproject.emxml"
166+
or (
167+
# Key images are stored in ".../LayersData/Layer/..."
168+
all(path in file_path.parts for path in ("LayersData", "Layer"))
169+
)
170+
):
171+
self._context = FIBContext("maps", self._basepath, self._token)
139172
return True
140173

174+
# Determine if it's from Meteor
175+
if (
176+
# Image metadata stored in "features.json" file
177+
file_path.name == "features.json" or ()
178+
):
179+
self._context = FIBContext("meteor", self._basepath, self._token)
180+
return True
181+
182+
# -----------------------------------------------------------------------------
141183
# Tomography and SPA workflow checks
184+
# -----------------------------------------------------------------------------
142185
if "atlas" in file_path.parts:
143186
self._context = AtlasContext(
144187
"serialem" if self._serialem else "epu", self._basepath, self._token
@@ -321,6 +364,12 @@ def _analyse(self):
321364
)
322365
self.post_transfer(transferred_file)
323366

367+
elif isinstance(self._context, FIBContext):
368+
logger.debug(
369+
f"File {transferred_file.name!r} will be processed as part of the FIB workflow"
370+
)
371+
self.post_transfer(transferred_file)
372+
324373
elif isinstance(self._context, AtlasContext):
325374
logger.debug(f"File {transferred_file.name!r} is part of the atlas")
326375
self.post_transfer(transferred_file)

src/murfey/client/contexts/fib.py

Lines changed: 214 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,174 @@
11
from __future__ import annotations
22

33
import logging
4+
import re
5+
import threading
46
from datetime import datetime
57
from pathlib import Path
6-
from typing import Dict, List, NamedTuple, Optional
8+
from typing import NamedTuple
9+
from xml.etree import ElementTree as ET
710

811
import xmltodict
912

1013
from murfey.client.context import Context
1114
from murfey.client.instance_environment import MurfeyInstanceEnvironment
12-
from murfey.util.client import capture_post
15+
from murfey.util.client import capture_post, get_machine_config_client
1316

1417
logger = logging.getLogger("murfey.client.contexts.fib")
1518

19+
lock = threading.Lock()
20+
1621

1722
class Lamella(NamedTuple):
1823
name: str
1924
number: int
20-
angle: Optional[float] = None
25+
angle: float | None = None
2126

2227

2328
class MillingProgress(NamedTuple):
2429
file: Path
2530
timestamp: float
2631

2732

33+
class ElectronSnapshotMetadata(NamedTuple):
34+
slot_num: int | None # Which slot in the FIB-SEM it is from
35+
image_num: int
36+
image_dir: str # Partial path from EMproject.emxml parent to the image
37+
status: str
38+
x_len: float | None
39+
y_len: float | None
40+
z_len: float | None
41+
x_center: float | None
42+
y_center: float | None
43+
z_center: float | None
44+
extent: tuple[float, float, float, float] | None
45+
rotation_angle: float | None
46+
47+
2848
def _number_from_name(name: str) -> int:
29-
return int(
30-
name.strip().replace("Lamella", "").replace("(", "").replace(")", "") or 1
49+
"""
50+
In the AutoTEM and Maps workflows for the FIB, the sites and images are
51+
auto-incremented with parenthesised numbers (e.g. "Lamella (2)"), with
52+
the first site/image typically not having a number.
53+
54+
This function extracts the number from the file name, and returns 1 if
55+
no such number is found.
56+
"""
57+
return (
58+
int(match.group(1))
59+
if (match := re.search(r"^[\w\s]+\((\d+)\)$", name)) is not None
60+
else 1
3161
)
3262

3363

64+
def _get_source(file_path: Path, environment: MurfeyInstanceEnvironment) -> Path | None:
65+
"""
66+
Returns the Path of the file on the client PC.
67+
"""
68+
for s in environment.sources:
69+
if file_path.is_relative_to(s):
70+
return s
71+
return None
72+
73+
74+
def _file_transferred_to(
75+
environment: MurfeyInstanceEnvironment, source: Path, file_path: Path, token: str
76+
) -> Path | None:
77+
"""
78+
Returns the Path of the transferred file on the DLS file system.
79+
"""
80+
machine_config = get_machine_config_client(
81+
str(environment.url.geturl()),
82+
token,
83+
instrument_name=environment.instrument_name,
84+
)
85+
86+
# Construct destination path
87+
base_destination = Path(machine_config.get("rsync_basepath", "")) / Path(
88+
environment.default_destinations[source]
89+
)
90+
# Add visit number to the path if it's not present in default destination
91+
if environment.visit not in environment.default_destinations[source]:
92+
base_destination = base_destination / environment.visit
93+
destination = base_destination / file_path.relative_to(source)
94+
return destination
95+
96+
97+
def _parse_electron_snapshot_metadata(xml_file: Path):
98+
metadata_dict = {}
99+
root = ET.parse(xml_file).getroot()
100+
datasets = root.findall(".//Datasets/Dataset")
101+
for dataset in datasets:
102+
# Extract all string-based values
103+
name, image_dir, status = [
104+
node.text
105+
if ((node := dataset.find(node_path)) is not None and node.text is not None)
106+
else ""
107+
for node_path in (
108+
".//Name",
109+
".//FinalImages",
110+
".//Status",
111+
)
112+
]
113+
114+
# Extract all float values
115+
cx, cy, cz, x_len, y_len, z_len, rotation_angle = [
116+
float(node.text)
117+
if ((node := dataset.find(node_path)) is not None and node.text is not None)
118+
else None
119+
for node_path in (
120+
".//BoxCenter/CenterX",
121+
".//BoxCenter/CenterY",
122+
".//BoxCenter/CenterZ",
123+
".//BoxSize/SizeX",
124+
".//BoxSize/SizeY",
125+
".//BoxSize/SizeZ",
126+
".//RotationAngle",
127+
)
128+
]
129+
130+
# Calculate the extent of the image
131+
extent = None
132+
if (
133+
cx is not None
134+
and cy is not None
135+
and x_len is not None
136+
and y_len is not None
137+
):
138+
extent = (
139+
x_len - (cx / 2),
140+
x_len + (cx / 2),
141+
y_len - (cy / 2),
142+
y_len - (cy / 2),
143+
)
144+
145+
# Append metadata for current site to dict
146+
metadata_dict[name] = ElectronSnapshotMetadata(
147+
slot_num=None if cx is None else (1 if cx < 0 else 2),
148+
image_num=_number_from_name(name),
149+
status=status,
150+
image_dir=image_dir,
151+
x_len=x_len,
152+
y_len=y_len,
153+
z_len=z_len,
154+
x_center=cx,
155+
y_center=cy,
156+
z_center=cz,
157+
extent=extent,
158+
rotation_angle=rotation_angle,
159+
)
160+
return metadata_dict
161+
162+
34163
class FIBContext(Context):
35164
def __init__(self, acquisition_software: str, basepath: Path, token: str):
36165
super().__init__("FIB", acquisition_software, token)
37166
self._basepath = basepath
38-
self._milling: Dict[int, List[MillingProgress]] = {}
39-
self._lamellae: Dict[int, Lamella] = {}
167+
self._milling: dict[int, list[MillingProgress]] = {}
168+
self._lamellae: dict[int, Lamella] = {}
169+
self._electron_snapshots: dict[str, Path] = {}
170+
self._electron_snapshot_metadata: dict[str, ElectronSnapshotMetadata] = {}
171+
self._electron_snapshots_submitted: set[str] = set()
40172

41173
def post_transfer(
42174
self,
@@ -45,6 +177,13 @@ def post_transfer(
45177
**kwargs,
46178
):
47179
super().post_transfer(transferred_file, environment=environment, **kwargs)
180+
if environment is None:
181+
logger.warning("No environment passed in")
182+
return
183+
184+
# -----------------------------------------------------------------------------
185+
# AutoTEM
186+
# -----------------------------------------------------------------------------
48187
if self._acquisition_software == "autotem":
49188
parts = transferred_file.parts
50189
if "DCImages" in parts and transferred_file.suffix == ".png":
@@ -123,3 +262,71 @@ def post_transfer(
123262
self._lamellae[number]._replace(
124263
angle=float(milling_angle.split(" ")[0])
125264
)
265+
# -----------------------------------------------------------------------------
266+
# Maps
267+
# -----------------------------------------------------------------------------
268+
elif self._acquisition_software == "maps":
269+
# Electron snapshot metadata file
270+
if transferred_file.name == "EMproject.emxml":
271+
# Extract all "Electron Snapshot" metadata and store it
272+
self._electron_snapshot_metadata = _parse_electron_snapshot_metadata(
273+
transferred_file
274+
)
275+
# If dataset hasn't been transferred, register it
276+
for dataset_name in list(self._electron_snapshot_metadata.keys()):
277+
if dataset_name not in self._electron_snapshots_submitted:
278+
if dataset_name in self._electron_snapshots:
279+
logger.info(f"Registering {dataset_name!r}")
280+
281+
## Workflow to trigger goes here
282+
283+
# Clear old entry after triggering workflow
284+
self._electron_snapshots_submitted.add(dataset_name)
285+
with lock:
286+
self._electron_snapshots.pop(dataset_name, None)
287+
self._electron_snapshot_metadata.pop(dataset_name, None)
288+
else:
289+
logger.debug(f"Waiting for image for {dataset_name}")
290+
# Electron snapshot image
291+
elif (
292+
"Electron Snapshot" in transferred_file.name
293+
and transferred_file.suffix in (".tif", ".tiff")
294+
):
295+
# Store file in Context memory
296+
dataset_name = transferred_file.stem
297+
if not (source := _get_source(transferred_file, environment)):
298+
logger.warning(f"No source found for file {transferred_file}")
299+
return
300+
if not (
301+
destination_file := _file_transferred_to(
302+
environment=environment,
303+
source=source,
304+
file_path=transferred_file,
305+
token=self._token,
306+
)
307+
):
308+
logger.warning(
309+
f"File {transferred_file.name!r} not found on storage system"
310+
)
311+
return
312+
self._electron_snapshots[dataset_name] = destination_file
313+
314+
if dataset_name not in self._electron_snapshots_submitted:
315+
# If the metadata and image are both present, register dataset
316+
if dataset_name in list(self._electron_snapshot_metadata.keys()):
317+
logger.info(f"Registering {dataset_name!r}")
318+
319+
## Workflow to trigger goes here
320+
321+
# Clear old entry after triggering workflow
322+
self._electron_snapshots_submitted.add(dataset_name)
323+
with lock:
324+
self._electron_snapshots.pop(dataset_name, None)
325+
self._electron_snapshot_metadata.pop(dataset_name, None)
326+
else:
327+
logger.debug(f"Waiting for metadata for {dataset_name}")
328+
# -----------------------------------------------------------------------------
329+
# Meteor
330+
# -----------------------------------------------------------------------------
331+
elif self._acquisition_software == "meteor":
332+
pass

0 commit comments

Comments
 (0)