11from __future__ import annotations
22
33import logging
4+ import re
5+ import threading
46from datetime import datetime
57from 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
811import xmltodict
912
1013from murfey .client .context import Context
1114from 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
1417logger = logging .getLogger ("murfey.client.contexts.fib" )
1518
19+ lock = threading .Lock ()
20+
1621
1722class Lamella (NamedTuple ):
1823 name : str
1924 number : int
20- angle : Optional [ float ] = None
25+ angle : float | None = None
2126
2227
2328class 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+
2848def _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+
34163class 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