Coverage for PyFHD/data_setup/uvfits.py: 67%
303 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-07-01 10:58 +0800
« prev ^ index » next coverage.py v7.9.1, created at 2025-07-01 10:58 +0800
1import numpy as np
2from numpy.typing import NDArray
3from astropy.io import fits
4from astropy.time import Time
5from astropy.io.fits.fitsrec import FITS_rec
6from astropy.io.fits.header import Header
7from pathlib import Path
8import logging
9from astropy.coordinates import EarthLocation
10import astropy
11from PyFHD.io.pyfhd_io import save
14def extract_header(
15 pyfhd_config: dict, logger: logging.Logger, model_uvfits=False
16) -> tuple[dict, np.recarray, FITS_rec, Header]:
17 """
18 Extract data from the uvfits header, the data extracted will contain metadata about the observation, antennas, visibilities
20 Parameters
21 ----------
22 uvfits_path : str
23 Path to the uvfits to open (either the data or the model)
24 pyfhd_config : dict
25 This is the config created from the argparse
26 logger : logging.Logger
27 The PyFHD logger
28 model_uvfits : bool
29 If True, load in the model uvfits. If False, load in a data uvfits file, by default False
31 Returns
32 -------
33 pyfhd_header : dict
34 The result from the extraction of the header of the UVFITS file, containing observation metadata mostly
35 params_data: np.recarray
36 The data from the UVFITS file, containing the visibility metadata
37 antenna_data : FITS_rec
38 The layout data which will be used in the create_layout function, mostly antenna metadata
39 antenna_header : Header
40 The layout header which will be used in the create_layout function, mostly antenna metadatas
42 Raises
43 ------
44 KeyError
45 If the UVFITS file doesn't contain all the data then a KeyError will be raised
46 """
48 if model_uvfits:
49 uvfits_path = Path(pyfhd_config["model_file_path"])
50 logger.info(f"Reading in model visibilities from: {uvfits_path}")
51 else:
52 uvfits_path = Path(
53 pyfhd_config["input_path"], pyfhd_config["obs_id"] + ".uvfits"
54 )
55 logger.info(f"Reading in visibilities from: {uvfits_path}")
57 # Retrieve all data from the observation
58 with fits.open(uvfits_path) as observation:
60 params_header = observation[0].header
61 params_data = observation[0].data
63 # Keep the layout header and data for the create_layout function
64 antenna_data = observation[1].data
65 antenna_header = observation[1].header
67 pyfhd_header = {}
68 # Retrieve data from the params_header
69 pyfhd_header["pol_dim"] = 2
70 pyfhd_header["freq_dim"] = 4
71 pyfhd_header["real_index"] = 0
72 pyfhd_header["imaginary_index"] = 1
73 pyfhd_header["weights_index"] = 2
74 pyfhd_header["n_tile"] = 128
75 pyfhd_header["naxis"] = params_header["naxis"]
76 pyfhd_header["n_params"] = params_header["pcount"]
77 pyfhd_header["n_baselines"] = params_header["gcount"]
78 pyfhd_header["n_complex"] = params_header["naxis2"]
79 pyfhd_header["n_pol"] = params_header["naxis3"]
80 pyfhd_header["n_freq"] = params_header["naxis4"]
81 pyfhd_header["freq_ref"] = params_header["crval4"]
82 pyfhd_header["freq_res"] = params_header["cdelt4"]
83 try:
84 pyfhd_header["dateobs"] = params_header["date-obs"]
85 except KeyError:
86 pyfhd_header["dateobs"] = params_header["dateobs"]
87 freq_ref_i = params_header["crpix4"] - 1
88 pyfhd_header["frequency_array"] = (
89 np.arange(pyfhd_header["n_freq"]) - freq_ref_i
90 ) * pyfhd_header["freq_res"] + pyfhd_header["freq_ref"]
91 try:
92 pyfhd_header["obsra"] = params_header["obsra"]
93 except KeyError:
94 logger.warning("OBSRA not found in UVFITS file")
95 pyfhd_header["obsra"] = params_header["ra"]
97 try:
98 pyfhd_header["obsdec"] = params_header["obsdec"]
99 except KeyError:
100 logger.warning("OBSDEC not found in UVFITS file")
101 pyfhd_header["obsdec"] = params_header["dec"]
102 # Put in locations of instrument from FITS file or from Astropy site data
103 # If you want to see the list of current site names using EarthLocation.get_site_names()
104 # If you want to use PyFHD with HERA in the future
105 # and make it compatible you might have to put in the lat/lon/alt yourself
106 try:
107 location = EarthLocation.of_site(pyfhd_config["instrument"])
108 except astropy.coordinates.errors.UnknownSiteException:
109 # If the site isn't known then select MWA, which no longer uses inbuilt corrdinates from the FHD repo.
110 logger.info(
111 f"Failed to load in the {pyfhd_config['instrument']} instrument location from astropy. If lon/lat/alt are not in the UVFITS things will fail."
112 )
113 # Can also do MWA or Murchison Widefield Array
114 location = EarthLocation("mwa")
116 try:
117 pyfhd_header["lon"] = params_header["lon"]
118 except KeyError:
119 pyfhd_header["lon"] = location.lon.deg
120 try:
121 pyfhd_header["lat"] = params_header["lat"]
122 except KeyError:
123 pyfhd_header["lat"] = location.lat.deg
124 try:
125 pyfhd_header["alt"] = params_header["alt"]
126 except KeyError:
127 pyfhd_header["alt"] = location.height.value
129 logger.info(
130 f"Setting {pyfhd_config['instrument']} instrument location to: lon {pyfhd_header['lon']:.2f}, lat {pyfhd_header['lat']:.2f}, alt {pyfhd_header['alt']:.2f}"
131 )
133 # Setup params list and names
134 param_list = []
135 ptype_list = ["PTYPE{}".format(i) for i in range(1, pyfhd_header["n_params"] + 1)]
136 for ptype in ptype_list:
137 param_list.append(params_header[ptype].strip())
138 param_names = []
139 for key in list(params_header.keys()):
140 if key.startswith("CTYPE"):
141 param_names.append(params_header[key].strip().lower())
143 # Validate params list
144 params_valid = True
145 pyfhd_header["uu_i"] = "UU" in param_list
146 if not pyfhd_header["uu_i"]: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 logger.error(
148 "Group parameter UU not found within uvfits params_header PTYPE keywords"
149 )
150 params_valid = False
152 pyfhd_header["vv_i"] = "VV" in param_list
153 if not pyfhd_header["vv_i"]: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 logger.error(
155 "Group parameter VV not found within uvfits params_header PTYPE keywords"
156 )
157 params_valid = False
159 pyfhd_header["ww_i"] = "WW" in param_list
160 if not pyfhd_header["ww_i"]: 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 logger.error(
162 "Group parameter WW not found within uvfits params_header PTYPE keywords"
163 )
164 params_valid = False
166 pyfhd_header["ant1_i"] = "ANTENNA1" in param_list
167 pyfhd_header["ant2_i"] = "ANTENNA2" in param_list
169 if not pyfhd_header["ant1_i"] or not pyfhd_header["ant2_i"]:
170 pyfhd_header["baseline_i"] = param_list.index("BASELINE")
171 if not pyfhd_header["baseline_i"]: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 logger.error(
173 "Group parameter BASELINE (or ANTENNA1 and ANTENNA2) not found within uvfits params_header PTYPE keywords"
174 )
175 params_valid = False
177 pyfhd_header["date_i"] = param_list.index("DATE")
178 if not pyfhd_header["date_i"]: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true
179 logger.error(
180 "Group parameter DATE not found within uvfits params_header PTYPE keywords"
181 )
182 params_valid = False
184 # Stop PyFHD if its not valid
185 if not params_valid: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 raise KeyError(
187 "One of these keys is missing from the UVFITS file: UU, VV, WW, BASELINE, DATE, check the log to see which one"
188 )
190 # Get the Julian Date
191 if param_list.count("DATE") > 1:
192 # This needs testing as Astropy scales automatically, which affects the DATE data read in, this should be the same though
193 pyfhd_header["jd0"] = (
194 params_header["PZERO{}".format(pyfhd_header["date_i"] + 1)]
195 + params_data["DATE"][0]
196 - params_data.columns[pyfhd_header["date_i"]].bzero
197 )
198 else:
199 # This is the bzero value used to normalize the value in Astropy for date
200 pyfhd_header["jd0"] = params_header[
201 "PZERO{}".format(pyfhd_header["date_i"] + 1)
202 ]
204 # Take the julian date and use that in dateobs in the fits format
205 if "jd0" in pyfhd_header.keys(): 205 ↛ 210line 205 didn't jump to line 210 because the condition on line 205 was always true
206 julian_time = Time(pyfhd_header["jd0"], format="jd")
207 julian_time.format = "fits"
208 pyfhd_header["dateobs"] = julian_time.value
209 # Probably won't reach here, if it does fill in jd0 from dateobs (fits to julian)
210 elif "dateobs" in pyfhd_header.keys():
211 fits_time = Time(pyfhd_header["dateobs"], format="fits")
212 fits_time.format = "jd"
213 pyfhd_header["jd0"] = fits_time.value
215 return pyfhd_header, params_data, antenna_header, antenna_data
218def create_params(
219 pyfhd_header: dict, params_data: np.recarray, logger: logging.Logger
220) -> dict:
221 """
222 Given the extracted header, params data from the uvfits file, create the params dictionary to store
223 the relevant visibility metadata
225 Parameters
226 ----------
227 pyfhd_header : dict
228 The resulting header fom the fits file stored in a dictonary
229 params_data : np.recarray
230 The data from the fits file as taken from astropy.io.fits.getdata
231 logger : logging.Logger
232 The PyFHD logger
234 Returns
235 -------
236 params : dict
237 The visibility metadata stored as a dictionary (instead of recarray as a dict is faster)
239 Raises
240 ======
241 KeyError
242 If the UVFITS data returned doesn't contain the variables then a KeyError will get thrown.
244 See Also
245 ========
246 astropy.io.fits.getdata : https://docs.astropy.org/en/stable/io/fits/api/files.html#getdata
247 extract_header : Extracts the header from the UVFITS file and returns the header and data
248 """
249 params = {}
250 # Retrieve params values
251 try:
252 params["uu"] = params_data["UU"].astype(np.float64)
253 params["vv"] = params_data["VV"].astype(np.float64)
254 params["ww"] = params_data["WW"].astype(np.float64)
255 # Astropy has already normalized the values by PZEROx, time in Julian
256 params["time"] = params_data["DATE"]
257 # Get baseline and antenna arrays
258 # The antenna arrays already exist then take those
259 if pyfhd_header["ant1_i"] and pyfhd_header["ant2_i"]: 259 ↛ 265line 259 didn't jump to line 265 because the condition on line 259 was always true
260 params["antenna1"] = params_data["ANTENNA1"]
261 params["antenna2"] = params_data["ANTENNA2"]
262 # Else calculate it from the baseline array
263 else:
264 # Calculate antenna_mod_index to check for bad fits
265 params["baseline_arr"] = params_data["BASELINE"]
266 baseline_min = np.min(params["baseline_arr"])
267 exponent = np.log(np.min(baseline_min)) / np.log(2)
268 antenna_mod_index = 2 ** np.floor(exponent)
269 tile_B_test = np.min(baseline_min) % antenna_mod_index
270 if tile_B_test > 1:
271 if baseline_min % 2 == 1:
272 antenna_mod_index /= 2 ** np.floor(np.log(tile_B_test) / np.log(2))
273 # Tile numbers start from 1
274 params["antenna1"] = np.floor(params["baseline_arr"] / antenna_mod_index)
275 params["antenna2"] = np.fix(params["baseline_arr"] % antenna_mod_index)
277 except KeyError as error:
278 logger.error(
279 f"Validation efforts failed, key not found in data, Traceback : {error}"
280 )
281 exit()
283 return params
286def extract_visibilities(
287 pyfhd_header: dict,
288 params_data: np.recarray,
289 pyfhd_config: dict,
290 logger: logging.Logger,
291) -> tuple[NDArray[np.complex128], NDArray[np.float64]]:
292 """
293 Extract the visibilities and their weights from the UVFITS data.
295 Parameters
296 ----------
297 pyfhd_header : dict
298 The resulting header fom the fits file stored in a dictonary
299 params_data : np.recarray
300 The data from the fits file as taken from astropy.io.fits.getdata
301 pyfhd_config : dict
302 This is the config created from the argprase
303 logger : logging.Logger
304 The PyFHD Logger
306 Returns
307 -------
308 vis_arr : NDArray[np.complex128]
309 The visibility array
310 vis_weights : NDArray[np.float64]
311 The visibility weights array
313 See Also
314 ========
315 astropy.io.fits.getdata : https://docs.astropy.org/en/stable/io/fits/api/files.html#getdata
316 extract_header : Extracts the header from the UVFITS file and returns the header and data
317 """
319 data_array = np.squeeze(params_data["DATA"])
320 # Set the number of polarizations
321 if pyfhd_config["n_pol"] == 0: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 n_pol = pyfhd_header["n_pol"]
323 else:
324 n_pol = pyfhd_config["n_pol"]
326 if data_array.ndim > 4: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 logger.error("No current support for PyFHD to support spectral dimensions yet")
328 exit()
329 else:
330 polarizations = np.arange(n_pol)
331 vis_arr = data_array[
332 :, :, polarizations, pyfhd_header["real_index"]
333 ] + data_array[:, :, polarizations, pyfhd_header["imaginary_index"]] * (1j)
334 vis_weights = data_array[:, :, polarizations, pyfhd_header["weights_index"]]
335 # Redo the shape so its the format per polarization, per frequency per baseline.
336 # Also ensure the types are double precision to ensure calculations from them result
337 # in double precision
338 return vis_arr.transpose().astype(np.complex128), vis_weights.transpose().astype(
339 np.float64
340 )
343def _check_layout_valid(
344 layout: dict, key: str, logger: logging.Logger, check_min_max=False
345):
346 """
347 Check if the key given is a valid part of the layout, if not give an error in the log.
348 The errors do not stop the run as it might only affect compatibility with other packages and
349 could be solved by editing or fixing the UVFITS file.
351 Parameters
352 ----------
353 layout : dict
354 The current layout
355 key : str
356 The key we're interested in validating
357 logger : logging.Logger
358 The logger
359 check_min_max : bool, optional
360 When True check if the min is the same as max, if so changes the value so its only one number, by default False
361 """
363 if check_min_max:
364 if type(layout[key]) == np.ndarray and np.min(layout[key]) == np.max( 364 ↛ 369line 364 didn't jump to line 369 because the condition on line 364 was always true
365 layout[key]
366 ):
367 layout[key] = layout[key][0]
369 if type(layout[key]) == np.ndarray and (layout[key].size != layout["n_antenna"]): 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true
370 logger.error(
371 f"The layout[{key}] array set is not the same size of the number of antennas. Check the UVFITS file for errors."
372 )
375def create_layout(
376 antenna_header: Header,
377 antenna_data: FITS_rec,
378 pyfhd_config: dict,
379 logger: logging.Logger,
380) -> dict:
381 """
382 Create a very explicit antenna and telescope position dictionary, incorperating
383 timing (e.g. time system, reference, leap seconds), location (e.g. array center,
384 coordinate frame, Earth's rotation), and antenna information (e.g. names, numbers,
385 coordinates, mount type, feed polarization). This is used to create the layout
386 dictionary which is compatible with pyuvdata.
388 Parameters
389 ----------
390 antenna_header : Header
391 The header from the second table of the observation
392 antenna_data : FITS_rec
393 The data from the second table of the observation
394 pyfhd_config : dict
395 PyFHD's configuration dictionary containing all the options for a run
396 logger : logging.Logger
397 PyFHD's logger
399 Returns
400 -------
401 layout: dict
402 The antenna layout dictionary compatible with pyuvdata
404 See Also
405 ---------
406 extract_header : Opens the UVFITS file and extracts the header and data, including the antenna_header and antenna_data.
407 """
409 layout = {}
411 # Extract data from the header
412 # array_center
413 try:
414 layout["array_center"] = [
415 antenna_header["arrayx"],
416 antenna_header["arrayy"],
417 antenna_header["arrayz"],
418 ]
419 except KeyError:
420 # if no center given, assume MWA center (Tingay et al. 2013, converted from lat/lon using pyuvdata)
421 logger.info(
422 "No center was given in the UVFITS file, assuming MWA is the array and using a default center for MWA"
423 )
424 layout["array_center"] = [
425 -2559454.07880307,
426 5095372.14368305,
427 -2849057.18534633,
428 ]
430 # Coordinate_frame
431 try:
432 layout["coordinate_frame"] = antenna_header["frame"]
433 except KeyError:
434 logger.info("Coordinate Frame is missing from the UVFITS file, using IRTF")
435 layout["coordinate_frame"] = "IRTF"
437 # Greenwich Sidereal Time
438 try:
439 layout["gst0"] = antenna_header["gstia0"]
440 except KeyError:
441 logger.warning(
442 "Greenwich sidereal time missing from UVFITS file gst0 will be -1"
443 )
444 layout["gst0"] = -1
446 # Earth's Rotation
447 try:
448 layout["earth_degpd"] = antenna_header["degpdy"]
449 except KeyError:
450 logger.info(
451 "degpdy is missing from the UVFITS file, using 360.985 for Earth's rotation in degrees"
452 )
453 layout["earth_degpd"] = 360.985
455 # Reference Date
456 try:
457 layout["refdate"] = antenna_header["rdate"]
458 except KeyError:
459 logger.warning("No refdate supplied in UVFITS file, set ref_date as -1")
460 layout["refdate"] = -1
462 # Time System
463 try:
464 layout["time_system"] = antenna_header["timesys"].strip()
465 except KeyError:
466 try:
467 layout["time_system"] = antenna_header["timsys"].strip()
468 except KeyError:
469 logger.warning(
470 "No Time System supplied in UVFITS file setting time system as UTC"
471 )
472 layout["time_system"] = "UTC"
474 # UT1UTC
475 try:
476 layout["dut1"] = antenna_header["ut1utc"]
477 except KeyError:
478 logger.info("UT1UTC is mising from UVFITS, using 0")
479 layout["dut1"] = 0
481 # DATUTC
482 try:
483 layout["diff_utc"] = antenna_header["datutc"]
484 except:
485 logger.info("No difference set between time_system and UTC, set to 0")
486 layout["diff_utc"] = 0
488 # Number of leap seconds
489 try:
490 layout["nleap_sec"] = antenna_header["iautc"]
491 except KeyError:
492 if layout["time_system"] == "IAT": 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true
493 logger.info(
494 "Time System is IAT and leap seconds is missing, using value from diff_utc(layout)/datutc(uvfits)"
495 )
496 layout["nleap_sec"] = layout["diff_utc"]
497 else:
498 logger.warning(
499 "Number of Leap Seconds is missing and the time system isn't IAT so we can't know the leap seconds, setting as -1"
500 )
502 # Polarization Type
503 try:
504 layout["pol_type"] = antenna_header["poltype"]
505 except KeyError:
506 logger.info(
507 "Polarization Type not in UVFITS file, Linear approximation for linear feeds is being used"
508 )
509 layout["pol_type"] = "X-Y LIN"
511 # Polarization Characteristics
512 try:
513 layout["n_pol_cal_params"] = antenna_header["nopcal"]
514 except KeyError:
515 logger.info(
516 "Polarization Characteristics of the feed not given in UVFITS file, Set n_pol_cal_params to 0"
517 )
518 layout["n_pol_cal_params"] = 0
520 # Number of antennas
521 try:
522 layout["n_antenna"] = antenna_header["naxis2"]
523 except KeyError:
524 logger.info("Number of antennas missing from header, set 128 as per MWA")
525 layout["n_antenna"] = 128
527 # Extract data from the data table
528 # Antenna Names
529 try:
530 layout["antenna_names"] = antenna_data["anname"]
531 except KeyError:
532 logger.warning("Antenna Names missing, replacing with a string of numbers")
533 layout["antenna_names"] = np.arange(layout["n_antenna"]).astype(str)
535 # Antenna Numbers
536 try:
537 layout["antenna_numbers"] = antenna_data["nosta"]
538 except KeyError:
539 layout.warning(
540 "Antenna Numbers missing replacing with an array of numbers of range 1: n_antenna"
541 )
542 layout["antenna_numbers"] = np.arange(1, layout["n_antenna"])
544 # Antenna Coordinates
545 try:
546 layout["antenna_coords"] = antenna_data["stabxyz"]
547 except KeyError:
548 logger.warning(
549 "Antenna Coordinates missing, replacing with a zero array of shape n_antenna, 3"
550 )
551 layout["antenna_coords"] = np.zeros((layout["n_antenna"], 3))
553 # Mount Type
554 try:
555 layout["mount_type"] = antenna_data["mntsta"]
556 except KeyError:
557 logger.warning("No Mount Type set, mount_type has been set to 0")
558 layout["mount_type"] = 0
560 # Axis Offset
561 try:
562 layout["axis_offset"] = antenna_data["staxof"]
563 except KeyError:
564 logger.warning("Axis Offset is not given, setting to 0")
565 layout["axis_offset"] = 0
567 # Feed Polarization of feed A (Pol A)
568 try:
569 layout["pola"] = antenna_data["poltya"]
570 except:
571 logger.warning("Pol A polarization not given setting to X")
572 layout["pola"] = "X"
574 # PolA Orientation
575 try:
576 layout["pola_orientation"] = antenna_data["polaa"]
577 except KeyError:
578 logging.warning("PolA orientation not given setting to 0")
579 layout["pola_orientation"] = 0
581 # PolA params
582 try:
583 layout["pola_cal_params"] = antenna_data["polcala"]
584 except KeyError:
585 logger.warning(
586 "PolA params is missing from the UVFITS, set to array of zeros of length n_pol_cal_params or 0"
587 )
588 if layout["n_pol_cal_params"] > 1: 588 ↛ 589line 588 didn't jump to line 589 because the condition on line 588 was never true
589 layout["pola_cal_params"] = np.zeros(layout["n_pol_cal_params"])
590 else:
591 layout["pola_cal_params"] = 0
593 # Feed Polarization of feed B (Pol B)
594 try:
595 layout["polb"] = antenna_data["poltyb"]
596 except:
597 logger.warning("Pol B polarization not given setting to Y")
598 layout["polb"] = "Y"
600 # PolB Orientation
601 try:
602 layout["polb_orientation"] = antenna_data["polab"]
603 except KeyError:
604 logging.warning("PolB orientation not given setting to 0")
605 layout["polb_orientation"] = 90
607 # PolB params
608 try:
609 layout["polb_cal_params"] = antenna_data["polcalb"]
610 except KeyError:
611 logger.warning(
612 "PolB params is missing from the UVFITS, set to array of zeros of length n_pol_cal_params or 0"
613 )
614 if layout["n_pol_cal_params"] > 1: 614 ↛ 615line 614 didn't jump to line 615 because the condition on line 614 was never true
615 layout["polb_cal_params"] = np.zeros(layout["n_pol_cal_params"])
616 else:
617 layout["polb_cal_params"] = 0
619 # Diameters
620 try:
621 layout["diameters"] = antenna_data["diameter"]
622 except KeyError:
623 logger.info("Diameters not in UVFITS file continuing.")
625 # Beam Full Width Half Maximum
626 try:
627 layout["beam_fwhm"] = antenna_data["beamfwhm"]
628 except KeyError:
629 logger.info("Beam Full Width Half maximum not present in UVFITS continuing.")
631 # Layout Validation
632 _check_layout_valid(layout, "antenna_names", logger)
633 _check_layout_valid(layout, "antenna_numbers", logger)
634 _check_layout_valid(layout, "mount_type", logger, check_min_max=True)
635 _check_layout_valid(layout, "axis_offset", logger, check_min_max=True)
636 _check_layout_valid(layout, "pola", logger)
637 _check_layout_valid(layout, "pola_orientation", logger, check_min_max=True)
638 _check_layout_valid(layout, "polb", logger)
639 _check_layout_valid(layout, "polb_orientation", logger, check_min_max=True)
641 save(Path(pyfhd_config["output_dir"], "layout.h5"), layout, "layout", logger)
643 return layout