Coverage for src/pytribeam/log.py: 94%

89 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2026-09-03 19:02 +0000

1#!/usr/bin/python3 

2""" 

3Experiment log-file utilities for `pytribeam`. 

4 

5This module provides helper functions for creating and updating `pytribeam` 

6experiment log files. Logs are stored as HDF5 files using `h5py`, with dataset 

7names, structured NumPy dtypes, and units defined by `pytribeam.constants.Constants`. 

8 

9The logging functions append timestamped rows to HDF5 datasets for experiment 

10settings, stage position, laser power, and specimen current. These records are 

11used to document experiment state before, during, and after automated 

12TriBeam/FIB/laser operations. 

13 

14## Typical usage 

15 

16```python 

17from pathlib import Path 

18 

19from pytribeam import log 

20from pytribeam.constants import Constants 

21 

22log_path = Path("experiment_log.h5") 

23config_path = Path("experiment.yml") 

24 

25log.create_file(log_path) 

26 

27log.experiment_settings( 

28 slice_number=1, 

29 step_number=1, 

30 log_filepath=log_path, 

31 yml_path=config_path, 

32) 

33 

34log.position( 

35 step_number=1, 

36 step_name="mill", 

37 slice_number=1, 

38 log_filepath=log_path, 

39 dataset_name=Constants.pre_position_dataset_name, 

40 current_position=current_position, 

41) 

42``` 

43 

44## Main entry points 

45 

46- `create_file`: create an HDF5 log file if it does not already exist. 

47- `current_time`: return the current time as a `tbt.TimeStamp`. 

48- `experiment_settings`: append the active experiment configuration to the log. 

49- `position`: append a stage-position record to a step-specific dataset. 

50- `laser_power`: append a laser-power measurement to a step-specific dataset. 

51- `specimen_current`: append a specimen-current measurement to a step-specific 

52 dataset. 

53- `yml_from_log`: extract a saved YAML configuration from an existing log file. 

54 

55## Log-file layout 

56 

57Experiment settings are stored in a top-level dataset named by 

58`Constants.settings_dataset_name`. 

59 

60Step-specific measurements are stored under groups named using the step number 

61and step name: 

62 

63```text 

64{step_number:02d}_{step_name}/{dataset_name} 

65``` 

66 

67For example: 

68 

69```text 

70Experiment Settings 

7101_mill/Position Before 

7201_mill/Position After 

7301_laser/Laser Power Before 

7401_laser/Laser Power After 

75``` 

76 

77The exact dataset names and structured dtypes are defined in 

78`pytribeam.constants.Constants`. 

79 

80## Timestamp convention 

81 

82Each logged row includes both a human-readable timestamp and a UNIX timestamp. 

83Timestamps are generated by `current_time` at the time the row is written. 

84 

85## Units 

86 

87Datasets that store physical quantities include HDF5 attributes describing their 

88units. Stage positions are recorded in millimeters and degrees, laser power is 

89recorded in watts, and specimen current is recorded in nanoamperes. 

90 

91## Notes 

92 

93This module assumes that log files are valid HDF5 files. Use `create_file` to 

94initialize new log files before appending records. 

95 

96<hr style="height: 12px; background-color: #333; border: none;"> 

97""" 

98 

99__all__ = [ 

100 "create_file", 

101 "current_time", 

102 "yml_from_log", 

103 "experiment_settings", 

104 "position", 

105 "laser_power", 

106 "specimen_current", 

107] 

108 

109# Default python modules 

110# from functools import singledispatch 

111from pathlib import Path 

112import datetime 

113 

114# Autoscript included modules 

115import numpy as np 

116import h5py 

117 

118# 3rd party module 

119 

120# Local scripts 

121from pytribeam.constants import Constants 

122import pytribeam.types as tbt 

123 

124 

125def create_file(path: Path) -> bool: 

126 """ 

127 Create a log file at the specified path. 

128 

129 This function creates a log file at the specified path if it does not already exist. 

130 

131 ## Parameters 

132 

133 - `path` (`Path`): The path where the log file should be created. 

134 

135 ## Returns 

136 

137 - `bool`: True if the log file is created successfully. 

138 

139 ## Raises 

140 

141 - `ValueError`: If the log file cannot be created. 

142 """ 

143 if not path.is_file(): 143 ↛ 148line 143 didn't jump to line 148 because the condition on line 143 was always true

144 log = h5py.File(path, "w") 

145 log.close() 

146 if path.is_file(): 146 ↛ 148line 146 didn't jump to line 148 because the condition on line 146 was always true

147 print(f'Logfile created at "{path}".') 

148 if path.is_file(): 148 ↛ 150line 148 didn't jump to line 150 because the condition on line 148 was always true

149 return True 

150 raise ValueError(f'Unable to create log file at location "{path}".') 

151 

152 

153def current_time() -> tbt.TimeStamp: 

154 """ 

155 Get the current time as a timestamp. 

156 

157 This function returns the current time as a `TimeStamp` object, including both human-readable and UNIX time formats. 

158 

159 ## Returns 

160 

161 - `tbt.TimeStamp`: The current time as a `TimeStamp` object. 

162 """ 

163 now = datetime.datetime.now() 

164 human_readable = now.strftime("%m/%d/%Y %H:%M:%S") 

165 unix_time = int(now.timestamp()) 

166 time = tbt.TimeStamp(human_readable=human_readable, unix=unix_time) 

167 return time 

168 

169 

170def yml_from_log( 

171 log_path_h5: Path, 

172 output_path_yml: Path, 

173 row: int, 

174 config_field: str = "Config File", 

175) -> bool: 

176 """ 

177 Extract YAML configuration from a log file and save it to an output path. 

178 

179 This function extracts the YAML configuration from a specified row in the log file and saves it to the output path. 

180 

181 ## Parameters 

182 

183 - `log_path_h5` (`Path`): The path to the log file. 

184 - `output_path_yml` (`Path`): The path to save the extracted YAML configuration. 

185 - `row` (`int`): The row number to extract the configuration from. 

186 - `config_field` (`str, optional`): The field name for the configuration in the log file (default is "Config File"). 

187 

188 ## Returns 

189 

190 - `bool`: True if the YAML configuration is extracted and saved successfully. 

191 """ 

192 # TODO enforce file formats on inputs 

193 with h5py.File(log_path_h5, "r") as file: 

194 data = np.array(file[Constants.settings_dataset_name][:]) 

195 settings = data[row][Constants.settings_dtype.names.index(config_field)].decode( 

196 "utf-8" 

197 ) 

198 

199 with open(output_path_yml, "w") as file: 

200 file.write(settings) 

201 

202 return True 

203 

204 

205def experiment_settings( 

206 slice_number: int, 

207 step_number: int, 

208 log_filepath: Path, 

209 yml_path: Path, 

210) -> bool: 

211 """ 

212 Log experiment settings to the log file. 

213 

214 This function logs the experiment settings from a YAML file to the log file. 

215 

216 ## Parameters 

217 

218 - `slice_number` (`int`): The slice number for the experiment. 

219 - `step_number` (`int`): The step number for the experiment. 

220 - `log_filepath` (`Path`): The path to the log file. 

221 - `yml_path` (`Path`): The path to the YAML file containing the experiment settings. 

222 

223 ## Returns 

224 

225 - `bool`: True if the experiment settings are logged successfully. 

226 """ 

227 dataset_name = Constants.settings_dataset_name 

228 settings_dtype = Constants.settings_dtype 

229 time = current_time() 

230 

231 with open(yml_path, "r") as yml_file: 

232 yml_data = yml_file.read() 

233 

234 if not log_filepath.exists(): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 log_filepath.touch() 

236 

237 with h5py.File(log_filepath, "r+") as log: 

238 if not dataset_name in log: 238 ↛ 245line 238 didn't jump to line 245 because the condition on line 238 was always true

239 settings = log.create_dataset( 

240 dataset_name, 

241 (0,), 

242 settings_dtype, 

243 maxshape=(None,), 

244 ) 

245 dataset = log[dataset_name] 

246 settings_data = np.array( 

247 [ 

248 ( 

249 slice_number, 

250 step_number, 

251 yml_data, 

252 time.human_readable, 

253 time.unix, 

254 ) 

255 ], 

256 settings_dtype, 

257 ) 

258 # add one row to table 

259 dataset.resize(dataset.shape[0] + 1, axis=0) 

260 dataset[-1:] = settings_data 

261 

262 return True 

263 

264 

265def position( 

266 step_number: int, 

267 step_name: str, 

268 slice_number: int, 

269 log_filepath: Path, 

270 dataset_name: str, 

271 current_position: tbt.StagePositionUser, 

272) -> bool: 

273 """ 

274 Log the current position to the log file. 

275 

276 This function logs the current position of the stage to the log file. 

277 

278 ## Parameters 

279 

280 - `step_number` (`int`): The step number for the experiment. 

281 - `step_name` (`str`): The name of the step. 

282 - `slice_number` (`int`): The slice number for the experiment. 

283 - `log_filepath` (`Path`): The path to the log file. 

284 - `dataset_name` (`str`): The name of the dataset to log the position to. 

285 - `current_position` (`tbt.StagePositionUser`): The current position of the stage. 

286 

287 ## Returns 

288 

289 - `bool`: True if the current position is logged successfully. 

290 """ 

291 print("\tLogging current position...") 

292 dataset_location = f"{step_number:02d}_{step_name}/{dataset_name}" 

293 time = current_time() 

294 

295 with h5py.File(log_filepath, "r+") as file: 

296 if not dataset_location in file: 

297 position = file.create_dataset( 

298 dataset_location, 

299 (0,), 

300 Constants.position_dtype, 

301 maxshape=(None,), 

302 ) 

303 position.attrs["X Units"] = np.string_("[mm]") 

304 position.attrs["Y Units"] = np.string_("[mm]") 

305 position.attrs["Z Units"] = np.string_("[mm]") 

306 position.attrs["T Units"] = np.string_("[degrees]") 

307 position.attrs["R Units"] = np.string_("[degrees]") 

308 

309 dataset = file[dataset_location] 

310 position_data = np.array( 

311 [ 

312 ( 

313 slice_number, 

314 round(current_position.x_mm, 6), 

315 round(current_position.y_mm, 6), 

316 round(current_position.z_mm, 6), 

317 round(current_position.t_deg, 6), 

318 round(current_position.r_deg, 6), 

319 time.human_readable, 

320 time.unix, 

321 ) 

322 ], 

323 Constants.position_dtype, 

324 ) 

325 # add one row to table 

326 dataset.resize(dataset.shape[0] + 1, axis=0) 

327 dataset[-1:] = position_data 

328 

329 return True 

330 

331 

332def laser_power( 

333 step_number: int, 

334 step_name: str, 

335 slice_number: int, 

336 log_filepath: Path, 

337 dataset_name: str, 

338 power_w: float, 

339) -> bool: 

340 """ 

341 Log the laser power to the log file. 

342 

343 This function logs the laser power to the log file. 

344 

345 ## Parameters 

346 

347 - `step_number` (`int`): The step number for the experiment. 

348 - `step_name` (`str`): The name of the step. 

349 - `slice_number` (`int`): The slice number for the experiment. 

350 - `log_filepath` (`Path`): The path to the log file. 

351 - `dataset_name` (`str`): The name of the dataset to log the laser power to. 

352 - `power_w` (`float`): The laser power in watts. 

353 

354 ## Returns 

355 

356 - `bool`: True if the laser power is logged successfully. 

357 """ 

358 print("\tLogging laser power...") 

359 dataset_location = f"{step_number:02d}_{step_name}/{dataset_name}" 

360 time = current_time() 

361 

362 with h5py.File(log_filepath, "r+") as file: 

363 if not dataset_location in file: 

364 laser_power = file.create_dataset( 

365 dataset_location, 

366 (0,), 

367 Constants.laser_power_dtype, 

368 maxshape=(None,), 

369 ) 

370 laser_power.attrs["Units"] = np.string_("[W]") 

371 

372 dataset = file[dataset_location] 

373 laser_power_data = np.array( 

374 [ 

375 ( 

376 slice_number, 

377 round(power_w, 6), 

378 time.human_readable, 

379 time.unix, 

380 ) 

381 ], 

382 Constants.laser_power_dtype, 

383 ) 

384 # add one row to table 

385 dataset.resize(dataset.shape[0] + 1, axis=0) 

386 dataset[-1:] = laser_power_data 

387 

388 return True 

389 

390 

391def specimen_current( 

392 step_number: int, 

393 step_name: str, 

394 slice_number: int, 

395 log_filepath: Path, 

396 dataset_name: str, 

397 specimen_current_na: float, 

398) -> bool: 

399 """ 

400 Log the specimen current to the log file. 

401 

402 This function logs the specimen current to the log file. 

403 

404 ## Parameters 

405 

406 - `step_number` (`int`): The step number for the experiment. 

407 - `step_name` (`str`): The name of the step. 

408 - `slice_number` (`int`): The slice number for the experiment. 

409 - `log_filepath` (`Path`): The path to the log file. 

410 - `dataset_name` (`str`): The name of the dataset to log the specimen current to. 

411 - `specimen_current_na` (`float`): The specimen current in nanoamperes. 

412 

413 ## Returns 

414 

415 - `bool`: True if the specimen current is logged successfully. 

416 """ 

417 print("\tLogging sample current...") 

418 dataset_location = f"{step_number:02d}_{step_name}/{dataset_name}" 

419 time = current_time() 

420 

421 with h5py.File(log_filepath, "r+") as file: 

422 if not dataset_location in file: 

423 specimen_current = file.create_dataset( 

424 dataset_location, 

425 (0,), 

426 Constants.specimen_current_dtype, 

427 maxshape=(None,), 

428 ) 

429 specimen_current.attrs["Units"] = np.string_("[nA]") 

430 

431 dataset = file[dataset_location] 

432 specimen_current_data = np.array( 

433 [ 

434 ( 

435 slice_number, 

436 round(specimen_current_na, 6), 

437 time.human_readable, 

438 time.unix, 

439 ) 

440 ], 

441 Constants.specimen_current_dtype, 

442 ) 

443 # add one row to table 

444 dataset.resize(dataset.shape[0] + 1, axis=0) 

445 dataset[-1:] = specimen_current_data 

446 print("\tLogging sample current complete...") 

447 

448 return True