11from __future__ import annotations
22
33import logging
4+ import re
45from collections import deque
56from contextlib import contextmanager
67from copy import deepcopy
1516import numpy as np
1617import pandas as pd
1718from matplotlib import pyplot as plt
19+ from pandas .errors import EmptyDataError , ParserError
1820from PIL .Image import Image
1921from typing_extensions import Self
2022
@@ -97,6 +99,25 @@ def __str__(self) -> str:
9799 def __len__ (self ) -> int :
98100 return len (self .table )
99101
102+ @classmethod
103+ def from_csv (cls , path : AnyPath ) -> Self :
104+ """Read self, table from a csv and metadata from its comment header."""
105+ with open (path , 'r' ) as csv_file :
106+ header = csv_file .readline ().lstrip ('# ' ).strip ()
107+ match = re .search (r'exposure=([^,]+),\s*continuous=(\S+)' , header )
108+ e = float (match .group (1 ))
109+ c = match .group (2 ) == 'True'
110+ table = pd .read_csv (path , sep = ',' , comment = '#' )
111+ return cls (exposure = e , continuous = c , ** {k : col for (k , col ) in table .items ()})
112+
113+ def to_csv (self , path : Path ) -> None :
114+ """Write self.table to a csv and metadata to its comment header."""
115+ header = f'# exposure={ self .exposure :.6g} , continuous={ self .continuous } \n '
116+ saved_cols = [c for c in self .table .columns if c != 'image' ]
117+ with open (path , 'w' ) as csv_file :
118+ csv_file .write (header )
119+ self .table .to_csv (path , mode = 'a' , header = header , index = False , columns = saved_cols )
120+
100121 @property
101122 def steps (self ) -> Iterator [Step ]:
102123 """Iterate over individual run `Step`s holding rows of `self.table`."""
@@ -277,6 +298,12 @@ def get_stage_rotation(self) -> CalibStageRotation:
277298 self .msg2 (m2 := 'Please run `instamatic.calibrate_stage_rotation` first.' )
278299 raise FastADTMissingCalibError (m1 + ' ' + m2 )
279300
301+ def get_tracking_directory (self ) -> Path :
302+ """Return a Path to tracking directory, create if it doesn't exist."""
303+ tracking_dir = self .path / 'tracking'
304+ tracking_dir .mkdir (parents = True , exist_ok = True )
305+ return tracking_dir
306+
280307 def determine_rotation_speed_and_exposure (self , run : Run ) -> tuple [float , float ]:
281308 """Closest possible speed setting & exposure considering dead time."""
282309 detector_dead_time = self .get_dead_time (run .exposure )
@@ -340,7 +367,12 @@ def start_collection(self, **params) -> None:
340367 if params ['tracking_algo' ] == 'manual' :
341368 self .binsize = self .ctrl .cam .default_binsize
342369 self .runs .tracking = TrackingRun .from_params (params )
343- self .determine_pathing_manually ()
370+ self .pathing_determine_manually ()
371+ self .pathing_save_to_files ()
372+ elif params ['tracking_algo' ] == 'load' :
373+ pathing_paths = params ['tracking_details' ].split (';' )
374+ self .pathing_load_from_files (* pathing_paths )
375+ self .pathing_save_to_files ()
344376 for pathing_run in self .runs .pathing :
345377 new_run = DiffractionRun .from_params (params )
346378 new_run .add_beamshifts (pathing_run )
@@ -363,7 +395,7 @@ def start_collection(self, **params) -> None:
363395 self .ctrl .stage .a = 0.0
364396
365397 @contextmanager
366- def displayed_pathing (self , step : Step ) -> None :
398+ def pathing_displayed (self , step : Step ) -> None :
367399 """Display step image with dots representing existing pathing."""
368400 draw = self .videostream_processor .draw
369401 instructions : list [draw .Instruction ] = []
@@ -379,9 +411,9 @@ def displayed_pathing(self, step: Step) -> None:
379411 for instruction in instructions :
380412 draw .instructions .remove (instruction )
381413
382- def determine_pathing_manually (self ) -> None :
383- """Determine the target beam shifts `delta_x ` and `delta_y ` manually,
384- based on the beam center found life (to find clicking offset) and
414+ def pathing_determine_manually (self ) -> None :
415+ """Determine the target `beamshift_x ` and `beamshift_y ` manually, based
416+ on the beam center found life (to find clicking offset) and
385417 `TrackingRun` to be used for crystal tracking in later experiment."""
386418 run : TrackingRun = cast (TrackingRun , self .runs .tracking )
387419 self .restore_fast_adt_diff_for_image ()
@@ -401,7 +433,7 @@ def determine_pathing_manually(self) -> None:
401433 while tracking_in_progress :
402434 while (step := self .steps .get ()) is not None :
403435 self .msg1 (f'Click on tracked point: { step .summary } .' )
404- with self .displayed_pathing (step = step ), self .click_listener :
436+ with self .pathing_displayed (step = step ), self .click_listener :
405437 click = self .click_listener .get_click ()
406438 click_xy = np .array (click .xy ) * self .binsize
407439 delta_yx = (click_xy - obs_beampixel_xy )[::- 1 ]
@@ -417,7 +449,7 @@ def determine_pathing_manually(self) -> None:
417449 self .msg1 ('Displaying tracking. Click LEFT mouse button to start the experiment,' )
418450 self .msg2 ('MIDDLE to track another point, or RIGHT to cancel the experiment.' )
419451 for step in sawtooth (self .runs .tracking .steps ):
420- with self .displayed_pathing (step = step ):
452+ with self .pathing_displayed (step = step ):
421453 image = self .videostream_processor .image
422454 image .info ['_annotated_runs' ] = len (self .runs .pathing )
423455 tracking_images [step .Index ] = image
@@ -428,6 +460,7 @@ def determine_pathing_manually(self) -> None:
428460 self .msg2 ('' )
429461 if click .button == MouseButton .RIGHT :
430462 self .msg1 (msg := 'Experiment abandoned after tracking.' )
463+ self .pathing_save_to_files ()
431464 raise FastADTEarlyTermination (msg )
432465 if click .button == MouseButton .LEFT :
433466 tracking_in_progress = False
@@ -436,15 +469,31 @@ def determine_pathing_manually(self) -> None:
436469 self .steps .put (new_step )
437470 break
438471
439- drc = self .path / 'tracking'
440- drc .mkdir (parents = True , exist_ok = True )
472+ td = self .get_tracking_directory ()
441473 with self .ctrl .cam .blocked ():
442474 for step , image in zip (run .steps , tracking_images ):
443475 i = f'image{ step .Index :02d} _al{ step .alpha :+03.0f} .png' .replace ('+' , '0' )
444476 if image is None or image .info ['_annotated_runs' ] < len (self .runs .pathing ):
445- with self .displayed_pathing (step = step ):
477+ with self .pathing_displayed (step = step ):
446478 image = self .videostream_processor .image
447- self .videostream_processor .vsf .save_image (image = image , path = drc / i )
479+ self .videostream_processor .vsf .save_image (image = image , path = td / i )
480+
481+ def pathing_load_from_files (self , * paths : AnyPath ) -> None :
482+ """Load pathing runs with 'beamshift_x/y' from existing csv files."""
483+ for path in paths :
484+ try :
485+ tracking = TrackingRun .from_csv (path = path )
486+ if m := {'beamshift_x' , 'beamshift_y' } - set (tracking .table .columns ):
487+ raise KeyError (f'Missing columns: { ", " .join (m )} ' )
488+ self .runs .pathing .append (tracking )
489+ except (EmptyDataError , KeyError , OSError , ParserError ) as e :
490+ self .msg2 (f'{ type (e ).__name__ } : { e } while loading "{ path } ".' )
491+
492+ def pathing_save_to_files (self ) -> None :
493+ """Save all pathing runs to separate tracking_# files."""
494+ td = self .get_tracking_directory ()
495+ for i , run in enumerate (self .runs .pathing ):
496+ run .to_csv (path = td / f'path_{ i + 1 } .csv' )
448497
449498 def collect_run (self , run : Run ) -> None :
450499 """Collect `run.steps` and place them in `self.steps` Queue."""
0 commit comments