plato-fits 2024.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1221 @@
1
+ import logging
2
+ import warnings
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ from astropy.io import fits
7
+ from astropy.io.ascii.cparser import AstropyWarning
8
+ from egse.settings import Settings
9
+ from egse.setup import Setup
10
+ from egse.setup import SetupError
11
+ from egse.spw import DataPacket
12
+ from egse.spw import DataPacketType
13
+ from egse.state import GlobalState
14
+ from egse.persistence import PersistenceLayer
15
+ from egse.system import time_since_epoch_1958
16
+
17
+ LOGGER = logging.getLogger(__name__)
18
+
19
+ CCD_SETTINGS = Settings.load("CCD")
20
+
21
+
22
+ class FITSPersistenceError(Exception):
23
+ """ Error for the FITS persistence layer."""
24
+ pass
25
+
26
+
27
+ class FITS(PersistenceLayer):
28
+ """ Persistence layer that saves (image) data in a FITS file."""
29
+
30
+ extension = "fits"
31
+
32
+ warnings.simplefilter('ignore', category=AstropyWarning)
33
+
34
+ def __init__(self, filename: str, prep: dict):
35
+ """ Initialisation of the FITS persistence layer.
36
+
37
+ This consists of the following steps:
38
+
39
+ - Initialise the filepath (for the given filename);
40
+ - Fetch the register map from the DPU;
41
+ - Read all necessary data from that register map:
42
+ - Which CCD(s) will be read;
43
+ - Which side(s) of the CCD(s) will be read (E = left; F = right);
44
+ - Which rows and columns will be transmitted;
45
+ - How can we know whether all information has been received for a particular exposure;
46
+ - Initialisation of the (1D) arrays in which the received data will be stored;
47
+ - Initialisation of basic headers for the image, serial pre-scan(s), serial over-scan(s), and parallel
48
+ over-scan for any exposure.
49
+
50
+ Assumed is that the crucial parameters in the register map (N-FEE mode, v_start, v_end, h_end) will stay the
51
+ same throughout data acquisition and assembly of the FITS file. It is not explicitly checked whether this is
52
+ indeed the case.
53
+
54
+ All information (image data, serial pre-scan, serial over-scan, and parallel over-scan) will be stored in the
55
+ same FITS file. If the data of multiple CCDs would be transmitted, it is all stored in the same FITS file. The
56
+ extension name will indicate which kind of data it contains (image data, serial pre-scan, serial over-scan, or
57
+ parallel over-scan), for which CCD. In case of the serial scan maps, the extension will also indicate which
58
+ CCD side it applies to.
59
+
60
+ Args:
61
+ - filename: Name of the output FITS file.
62
+ - prep: Dictionary with the following information:
63
+ * v_start (int) and v_end(int): index of the first and the last row being transmitted;
64
+ * h_end (int): index of the last serial readout of the readout register;
65
+ * rows_final_dump:
66
+ * ccd_mode_config (egse.fee.n_fee_mode): readout mode;
67
+ * ccd_readout_order (List[int]): CCDs that will be read out;
68
+ * expected_last_packet_flags (List[bool]): expected last packet flags;
69
+ * obsid: observation identifier;
70
+ * cycle_time: image cycle time [s];
71
+ * cgse_version: version of the Common EGSE;
72
+ * setup: setup;
73
+ * register_map: FEE register map;
74
+ * vgd: configured VGD;
75
+ * in case of windowing mode or windowing pattern mode: dictionary with one window list for each CCD.
76
+ """
77
+
78
+ self.fee_side = GlobalState.setup.camera.fee.ccd_sides.enum
79
+
80
+ self._filepath = Path(filename)
81
+
82
+ # Which side(s) of which CCD(s) will be transmitted?
83
+ # (initialise the data arrays for each of these)
84
+
85
+ self.ccd_readout_order = prep["ccd_readout_order"] # CCD numbering [1-4]
86
+ self.selected_ccds = np.unique(self.ccd_readout_order)
87
+ LOGGER.debug(f"Selected CCDs: {self.selected_ccds}")
88
+
89
+ self.data_arrays = {}
90
+ self.init_data_arrays()
91
+
92
+ self.frame_number = {ccd: {self.fee_side.LEFT_SIDE: 0, self.fee_side.RIGHT_SIDE: 0} for ccd in self.selected_ccds}
93
+ self.timestamp = None
94
+ self.finetime = None
95
+
96
+ # How can you know whether or not all data for a given CCD has been received?
97
+ # (this depends on whether or not there is parallel over-scan data and on which CCD side(s) will be transmitted)
98
+
99
+ self.expected_last_packet_flags = prep["expected_last_packet_flags"] # To be received
100
+ self.received_last_packet_flags = {ccd: [False] * 4 for ccd in self.selected_ccds} # Actually received
101
+
102
+ LOGGER.debug(f"Init last packets flag: {self.received_last_packet_flags}")
103
+
104
+ # Readout mode
105
+
106
+ self.ccd_mode_config = prep["ccd_mode_config"]
107
+ self.is_windowing_mode = self.check_readout_mode()
108
+
109
+ # if self.is_windowing_mode:
110
+ #
111
+ # self.windows = self.init_window_list(prep["window_list"])
112
+
113
+ # Define the values of the WCS keywords
114
+
115
+
116
+ self.v_start = prep["v_start"] # First transmitted row
117
+ self.v_end = prep["v_end"] # Last transmitted row
118
+ self.h_end = prep["h_end"] # Last transmitted column
119
+ self.rows_final_dump = prep["rows_final_dump"] # Number of rows to be dumped after readout
120
+ self.cycle_time = prep["cycle_time"] # Image cycle time [s]
121
+ self.cgse_version = prep["cgse_version"] # Version of the Common EGSE
122
+ self.obsid = prep["obsid"]
123
+
124
+ self.register_map = prep["register_map"] # Register map
125
+
126
+ # Read information from the setup
127
+
128
+ self.setup: Setup = prep["setup"]
129
+
130
+ self.site_name = self.setup["site_id"] # Site ID
131
+ self.setup_id = self.setup.get_id() # Setup ID
132
+ self.camera_id = self.setup.camera.get("ID") # Camera ID (None if not present in the setup)
133
+
134
+ self.readout_time = self.calculate_readout_time(self.setup) # Readout time [s]
135
+ try:
136
+ self.exposure_time = self.cycle_time - self.readout_time # Exposure time [s]
137
+ except TypeError:
138
+ # Image cycle time is unknown (None)
139
+ self.exposure_time = None
140
+
141
+ self.has_serial_overscan = self.h_end >= CCD_SETTINGS.LENGTH_SERIAL_PRESCAN + CCD_SETTINGS.NUM_COLUMNS / 2
142
+ self.has_parallel_overscan = self.v_end >= CCD_SETTINGS.NUM_ROWS
143
+
144
+ # Create basic WCS
145
+ # (part of the information can only be determined when the data is assembled)
146
+
147
+ self.image_header = self.create_base_image_wcs()
148
+ self.serial_prescan_header = self.create_base_serial_prescan_wcs()
149
+ self.serial_overscan_header = self.create_base_serial_overscan_wcs()
150
+ self.parallel_overscan_header = self.create_base_parallel_overscan_wcs()
151
+
152
+ self.is_fits_file_open = False
153
+
154
+ def calculate_readout_time(self, setup: Setup):
155
+ """ Calculate the readout time.
156
+
157
+ The readout time consists of:
158
+
159
+ - clearout for the rows up to v_start;
160
+ - reading (i.e. parallel transfer of the row + serial transfer of all its pixels up to h_end) rows v_start
161
+ to v_end;
162
+ - dumping rows_final_dump.
163
+
164
+ Returns: Readout time for the requested part of a single CCD side [s].
165
+ """
166
+
167
+ time_row_parallel = setup.camera.fee.time_row_parallel
168
+ time_row_clearout = setup.camera.fee.time_row_clearout
169
+ time_pixel_readout = setup.camera.fee.time_pixel_readout
170
+
171
+ time_read_rows = (self.v_end - self.v_start + 1) * (time_row_parallel + (self.h_end + 1) * time_pixel_readout)
172
+ time_dump_rows = (self.v_start + self.rows_final_dump) * time_row_clearout
173
+
174
+ return time_read_rows + time_dump_rows
175
+
176
+ def get_vgd(self):
177
+ """ Extract the VGD voltage from the register map.
178
+
179
+ Return: Configured VGD voltage [V].
180
+ """
181
+
182
+ vgd_19 = self.register_map[('reg_19_config', 'ccd_vgd_config')]
183
+ vgd_20 = self.register_map[('reg_20_config', 'ccd_vgd_config')]
184
+
185
+ return ((vgd_20 << 4) + vgd_19) / 1000 * 5.983
186
+
187
+ def get_ccd2_vrd(self):
188
+ """
189
+ Extract the VRD voltage for CCD2 from the register map.
190
+
191
+ NOTE: Use this function only for the FM cameras.
192
+
193
+ Return: Configured VRD voltage.
194
+ """
195
+ vrd_18 = self.register_map[('reg_18_config', 'ccd2_vrd_config')]
196
+ vrd_19 = self.register_map[('reg_19_config', 'ccd2_vrd_config')]
197
+
198
+ return int(f'0x{vrd_19:x}{vrd_18:x}', 16)
199
+
200
+
201
+ def get_ccd3_vrd(self):
202
+ """
203
+ Extract the VRD voltage for CCD3 from the register map.
204
+
205
+ NOTE: Use this function only for the EM camera.
206
+
207
+ Return: Configured VRD voltage.
208
+ """
209
+ vrd_18 = self.register_map[('reg_18_config', 'ccd3_vrd_config')]
210
+ vrd_19 = self.register_map[('reg_19_config', 'ccd3_vrd_config')]
211
+
212
+ return int(f'0x{vrd_19:x}{vrd_18:x}', 16)
213
+
214
+
215
+ def init_data_arrays(self):
216
+ """ Initialise data arrays in which the data content of the SpW packets will be dumped.
217
+
218
+ At this point, we have already determined which side(s) of which CCD(s) will be read out. For each of them,
219
+ a placeholder will be foreseen in a dedicated dictionary. The structure of this dictionary is the following,
220
+ in case both sides of all CCDs will be read:
221
+
222
+ data_arrays[fee_side.LEFT_SIDE][1] # left side of CCD1
223
+ data_arrays[fee_side.LEFT_SIDE][2] # left side of CCD2
224
+ data_arrays[fee_side.LEFT_SIDE][3] # left side of CCD3
225
+ data_arrays[fee_side.LEFT_SIDE][4] # left side of CCD4
226
+ data_arrays[fee_side.RIGHT_SIDE][1] # right side of CCD1
227
+ data_arrays[fee_side.RIGHT_SIDE][2] # right side of CCD2
228
+ data_arrays[fee_side.RIGHT_SIDE][3] # right side of CCD3
229
+ data_arrays[fee_side.RIGHT_SIDE][4] # right side of CCD4
230
+
231
+ In case not all CCDs will be read out and/or only one side, placeholders will only be foreseen for the relevant
232
+ CCD data.
233
+
234
+ Returns: Dictionary with placeholders for the data arrays of the selected CCD sides.
235
+ """
236
+
237
+ left_side_arrays = {ccd: np.array([], dtype=np.uint16) for ccd in self.selected_ccds}
238
+ self.data_arrays[self.fee_side.LEFT_SIDE] = left_side_arrays
239
+
240
+ right_side_arrays = {ccd: np.array([], dtype=np.uint16) for ccd in self.selected_ccds}
241
+ self.data_arrays[self.fee_side.RIGHT_SIDE] = right_side_arrays
242
+
243
+ def clear_for_next_exposure(self, ccd_number: int, ccd_side):
244
+ """ Indicate that no data has been received yet for the next exposure of the given CCD.
245
+
246
+ At the end of an exposure, when the data have been assembled and written to FITS:
247
+
248
+ - clear the data arrays for the next exposure;
249
+ - indicate that the last packet has not been received yet for the next exposure;
250
+ - clear the timestamp for the next exposure.
251
+
252
+ Args:
253
+ - ccd_number: CCD identifier (1/2/3/4).
254
+ - ccd_side: CCD side from which the last received data originates.
255
+ """
256
+
257
+ for ccd_side in self.fee_side:
258
+ self.clear_data_arrays(ccd_number, ccd_side)
259
+
260
+ self.clear_last_packet_received(ccd_number)
261
+
262
+ def clear_data_arrays(self, ccd_number: int, ccd_side):
263
+ """ Clear the data arrays for the given CCD.
264
+
265
+ At the end of an exposure, when the data have been assembled and written to FITS, the data arrays must be
266
+ cleared for the next exposure.
267
+
268
+ Args:
269
+ - ccd_number: CCD identifier (1/2/3/4).
270
+ """
271
+
272
+ self.data_arrays[ccd_side][ccd_number] = np.array([], dtype=np.uint16)
273
+
274
+ def clear_last_packet_received(self, ccd_number: int):
275
+ """ Clear the information about the last packet being received for the given CCD.
276
+
277
+ At the end of an exposure, when the data have been assembled and written to FITS, it must be indicated that the
278
+ last packet has not been received for the next exposure for the given CCD.
279
+
280
+ Args:
281
+ - ccd_number: CCD identifier (1/2/3/4).
282
+ """
283
+
284
+ self.received_last_packet_flags[ccd_number] = [False for el in self.received_last_packet_flags[ccd_number]]
285
+
286
+ # def get_data_array(self, ccd_number: int, ccd_side: fee_side):
287
+ # """ Return the data array for the given side of the given CCD.
288
+ #
289
+ # In this array all SpW data concerning the serial pre-scan, image, serial over-scan, and parallel over-scan
290
+ # will be dumped (in 1D). If the last data packet has been received, the different parts will be extracted and
291
+ # written to FITS.
292
+ #
293
+ # Args:
294
+ # - ccd_number: CCD identifier (1/2/3/4).
295
+ # - ccd_side: CCD side.
296
+ #
297
+ # Returns: Data array for the given side of the given CCD.
298
+ # """
299
+ #
300
+ # return self.data_arrays[ccd_side][ccd_number]
301
+
302
+ def check_readout_mode(self):
303
+ """ For now only checks whether the N-FEE is in the correct mode, i.e. full-image (pattern) mode.
304
+
305
+ In the future, if deemed necessary, windowing (pattern) mode may be implemented as well.
306
+ """
307
+
308
+ if self.ccd_mode_config in [n_fee_mode.FULL_IMAGE_MODE, n_fee_mode.FULL_IMAGE_PATTERN_MODE,
309
+ n_fee_mode.PARALLEL_TRAP_PUMPING_1_MODE, n_fee_mode.PARALLEL_TRAP_PUMPING_2_MODE,
310
+ n_fee_mode.SERIAL_TRAP_PUMPING_1_MODE, n_fee_mode.SERIAL_TRAP_PUMPING_2_MODE]:
311
+
312
+ return False
313
+
314
+ if self.ccd_mode_config in [n_fee_mode.WINDOWING_PATTERN_MODE, n_fee_mode.WINDOWING_MODE]:
315
+
316
+ return True
317
+
318
+ else:
319
+
320
+ raise FITSPersistenceError("Construction of FITS files from SpW packets only implemented for full-image "
321
+ "(pattern) mode, windowing (pattern) mode, and parallel/serial trap pumping 1/2 "
322
+ "mode")
323
+
324
+ # def init_window_list(self, window_list: dict):
325
+ # """ Compile the window list.
326
+ #
327
+ # For each of the CCDs that will be read out, execute the following steps:
328
+ #
329
+ # - For each of the CCD sides that will be transmitted, identify which pixels in the compound 2D array (i.e.
330
+ # in which the image and the scan maps are still glued together) will be transmitted. Store their index
331
+ # in the corresponding 1D array in a dictionary.
332
+ # - Store the pixel coordinates of the lower left corner of the window (in the CCD reference frame) of all
333
+ # windows in the image area.
334
+ # """
335
+ #
336
+ # window_indices = {ccd_side: {} for ccd_side in self.selected_ccd_side}
337
+ #
338
+ # for ccd_number in self.selected_ccds:
339
+ #
340
+ # # Get the window information for the current CCD
341
+ #
342
+ # ccd_window_list_obj: WindowList = window_list[ccd_number]
343
+ # ccd_window_list = ccd_window_list_obj.get_window_list() # Ordered set of windows
344
+ # ccd_window_num_columns, ccd_window_num_rows = ccd_window_list_obj.get_window_size() # Window size
345
+ #
346
+ # ccd_window_x_array = np.array([])
347
+ # ccd_window_y_array = np.array([])
348
+ #
349
+ # ccd_window_indices = {ccd_side: np.array([]) for ccd_side in self.selected_ccd_side}
350
+ #
351
+ # for (x_window, y_window, ccd_side_window) in ccd_window_list:
352
+ #
353
+ # # Calculate the index in the 1D array
354
+ #
355
+ # for row in range(y_window, y_window + ccd_window_num_rows + 1):
356
+ #
357
+ # for column in range(x_window, x_window + ccd_window_num_columns + 1):
358
+ #
359
+ # index = row * (self.v_end - self.v_start + 1) + column
360
+ # ccd_window_indices[ccd_side_window] = np.append(ccd_window_indices[ccd_side_window], index)
361
+ #
362
+ # # Store the coordinates of the lower left corner of the windows in the CCD reference frame
363
+ # # (only for the windows in the image area)
364
+ #
365
+ # x_to_append = x_window - CCD_SETTINGS.LENGTH_SERIAL_PRESCAN
366
+ #
367
+ # # TODO Should we check that the window is on the image area?
368
+ #
369
+ # if ccd_side_window == fee_side.F_SIDE:
370
+ #
371
+ # x_to_append = CCD_SETTINGS.NUM_COLUMNS / 2 - 1 - x_to_append
372
+ #
373
+ # if fee_side.E_SIDE in self.selected_ccd_side:
374
+ #
375
+ # x_to_append += CCD_SETTINGS.NUM_COLUMNS / 2
376
+ #
377
+ # ccd_window_x_array = np.append(ccd_window_x_array, x_to_append)
378
+ # ccd_window_y_array = np.append(ccd_window_y_array, y_window)
379
+ #
380
+ # for ccd_side in self.selected_ccd_side:
381
+ #
382
+ # window_indices[ccd_side][ccd_number] = np.sort(np.unique(ccd_window_indices[ccd_side]))
383
+ #
384
+ # rows = fits.Column("Rows", format="I", array=ccd_window_y_array)
385
+ # columns = fits.Column("Columns", format="I", array=ccd_window_x_array)
386
+ # table = fits.BinTableHDU.from_columns([rows, columns])
387
+ # table.header["EXTNAME"] = f"Windows"
388
+ # table.header["EXTVER"] = ccd_number
389
+ # table.header["CCD_ID"] = (ccd_number, "CCD identifier",)
390
+ #
391
+ # # TODO
392
+ #
393
+ # # fits.append(table)
394
+ #
395
+ # # with fits.open(self._filepath, mode="append") as hdul:
396
+ # #
397
+ # # hdul.append(table)
398
+ #
399
+ # return window_indices
400
+
401
+ def create_primary_header(self):
402
+ """ Creates the primary header (i.e. the header of the primary HDU).
403
+
404
+ This contains information that is specific for the camera.
405
+ """
406
+
407
+ primary_header = fits.PrimaryHDU().header
408
+
409
+ primary_header["LEVEL"] = 1 # Flat structure
410
+
411
+ primary_header["V_START"] = (self.v_start, "Index of 1st row that is transmitted (counting starts at 0)")
412
+ primary_header["V_END"] = (self.v_end, "Index of last row that is transmitted (counting starts at 0)")
413
+ primary_header["H_END"] = (self.h_end, "Number of serial register transfers")
414
+ primary_header["ROWS_FINAL_DUMP"] = (self.rows_final_dump, "Number of rows for clearout after readout")
415
+ primary_header["READ_MODE"] = (n_fee_mode(self.ccd_mode_config).name, "N-FEE operating mode")
416
+
417
+ primary_header["CI_WIDTH"] = (self.register_map["charge_injection_width"],
418
+ "Number of rows in each charge injection region")
419
+ primary_header["CI_GAP"] = (self.register_map["charge_injection_gap"],
420
+ "Number of rows between charge injection regions")
421
+ primary_header["PARALLEL_TOI_PERIOD"] = (self.register_map["parallel_toi_period"],
422
+ "Duration of a parallel overlap period (TOI) [us]")
423
+ primary_header["PARALLEL_CLK_OVERLAP"] = (self.register_map["parallel_clk_overlap"],
424
+ "Extra parallel clock overlap [us]")
425
+ primary_header["CI_EN"] = (self.register_map["charge_injection_width"],
426
+ "Charge injection enabled (1) / disabled (0)")
427
+ primary_header["TRI_LEVEL_CLK_EN"] = (self.register_map["tri_level_clk_en"],
428
+ "Generating bi-level parallel clocks (0) / tri-level parallel clocks (1)")
429
+ primary_header["IMG_CLK_DIR"] = (self.register_map["img_clk_dir"],
430
+ "Generating reverse parallel clocks (1) / normal forward parallel clocks (0)")
431
+ primary_header["REG_CLK_DIR"] = (self.register_map["reg_clk_dir"],
432
+ "Generating reverse serial clocks (1) / normal forward serial clocks (0)")
433
+ primary_header["PACKET_SIZE"] = (self.register_map["packet_size"],
434
+ "Packet size = load bytes + 10 (header bytes)")
435
+
436
+ primary_header["TRAP_PUMP_DWELL_CTR"] = (self.register_map["Trap_Pumping_Dwell_counter"],
437
+ "Dwell timer for trap pumping [ns]")
438
+ primary_header["SENSOR_SEL"] = (self.register_map["sensor_sel"], "CCD port data transmission selection control")
439
+ primary_header["SYNC_SEL"] = (self.register_map["sync_sel"], "Internal (1) / external (0) sync")
440
+ primary_header["DIGITISE_EN"] = (self.register_map["digitise_en"],
441
+ "Digitised data transferred to the N-DPU (1) or not (0) during image mode")
442
+ primary_header["DG_EN"] = (self.register_map["DG_en"], "Dump gate high (1) / low (0)")
443
+ primary_header["CCD_READ_EN"] = (self.register_map["ccd_read_en"], "CCD readout enabled (1) / disabled (0)")
444
+ primary_header["CONV_DLY"] = (self.register_map["conv_dly"],
445
+ "Delay value from rising edge of CCD_R_EF_DRV (where ADC convert pulse "
446
+ "is generated) [ns]")
447
+ primary_header["HIGH_PRECISION_HK_EN"] = (self.register_map["High_precision_HK_en"],
448
+ "Sending high-precision HK (1) / pixel data (0)")
449
+
450
+ primary_header["CCD_VOD"] = (self.register_map["ccd_vod_config"], "Configured VOD")
451
+
452
+ primary_header["CCD1_VRD"] = (self.register_map["ccd1_vrd_config"], "Configured VRD for CCD1")
453
+ if PLATO_CAMERA_IS_EM:
454
+ primary_header["CCD2_VRD"] = (self.register_map["ccd2_vrd_config"], "Configured VRD for CCD2")
455
+ primary_header["CCD3_VRD"] = (self.get_ccd3_vrd(), "Configured VRD for CCD3")
456
+ else:
457
+ primary_header["CCD2_VRD"] = (self.get_ccd2_vrd(), "Configured VRD for CCD2")
458
+ primary_header["CCD3_VRD"] = (self.register_map["ccd3_vrd_config"], "Configured VRD for CCD3")
459
+
460
+ primary_header["CCD4_VRD"] = (self.register_map["ccd4_vrd_config"], "Configured VRD for CCD4")
461
+ primary_header["CCD_VOG"] = (self.register_map["ccd_vog_config"], "Configured VOG")
462
+ primary_header["CCD_VGD"] = (self.get_vgd(), "Configured VGD [V]")
463
+ primary_header["CCD_IG_HI"] = (self.register_map["ccd_ig_hi_config"], "Configured IG-high")
464
+ primary_header["CCD_IG_LO"] = (self.register_map["ccd_ig_lo_config"], "Configured IG-high")
465
+ primary_header["TRK_HLD_HI"] = (self.register_map["trk_hld_hi"], "Track and hold high")
466
+ primary_header["TRK_HLD_LO"] = (self.register_map["trk_hld_lo"], "Track and hold low")
467
+ primary_header["CONT_RST_ON"] = (self.register_map["cont_rst_on"],
468
+ "When 1, FPGA generates continuous reset clock during readout")
469
+ if not PLATO_CAMERA_IS_EM:
470
+ primary_header["CONT_CDSCLP_ON"] = (self.register_map["cont_cdsclp_on"],
471
+ "When 1, FPGA generates continuous CDS clamp during readout")
472
+ primary_header["CONT_ROWCLP_ON"] = (self.register_map["cont_rowclp_on"],
473
+ "When 1, FPGA generates continuous row clamp during readout")
474
+ primary_header["R_CFG1"] = (self.register_map["r_cfg1"], "Clock cycle for Rph3-low, Rph1-high")
475
+ primary_header["R_CFG2"] = (self.register_map["r_cfg2"], "Clock cycle for Rph1-low, Rph2-high")
476
+ primary_header["CDSCLP_LO"] = (self.register_map["cdsclp_lo"], "Clock cycle for cdsclp low")
477
+ if not PLATO_CAMERA_IS_EM:
478
+ primary_header["ADC_PWRDN_EN"] = (self.register_map["adc_pwrdn_en"],
479
+ "ADC power-down enabled (0) / disabled (1)")
480
+ primary_header["CDSCLP_HI"] = (self.register_map["cdsclp_hi"], "Clock cycle for cdsclp high")
481
+ primary_header["ROWCLP_HI"] = (self.register_map["rowclp_hi"], "Clock cycle for rowclp high")
482
+ primary_header["ROWCLP_LO"] = (self.register_map["rowclp_lo"], "Clock cycle for rowclp low")
483
+ # primary_header["SURFACE_INV_CTR"] = (self.register_map["Surface_Inversion_counter"],
484
+ # "Surface inversion counter")
485
+ primary_header["READOUT_PAUSE_CTR"] = (self.register_map["Readout_pause_counter"], "Readout pause counter")
486
+ primary_header["TRAP_PUMP_SHUFFLE_CTR"] = (self.register_map["Trap_Pumping_Shuffle_counter"],
487
+ "Trap pumping shuffle counter")
488
+
489
+ # primary_header["FOCALLEN"] = (FOV_SETTINGS["FOCAL_LENGTH"], "Focal length [mm]")
490
+
491
+ # Additional keywords
492
+
493
+ primary_header["TELESCOP"] = "PLATO"
494
+ if self.camera_id:
495
+ primary_header["INSTRUME"] = (self.camera_id, "Camera ID")
496
+ primary_header["SITENAME"] = (self.site_name, "Name of the test site")
497
+ primary_header["SETUP"] = (self.setup_id, "Setup ID")
498
+ primary_header["CCD_READOUT_ORDER"] = (str(self.ccd_readout_order), "Transmitted CCDs")
499
+ primary_header["CYCLETIME"] = (self.cycle_time, "Image cycle time [s]")
500
+ primary_header["READTIME"] = (self.readout_time,
501
+ "Time needed to read out the requested part for a single CCD side [s]")
502
+
503
+ if self.register_map["sync_sel"] == 1: # See https://github.com/IvS-KULeuven/plato-common-egse/issues/2475
504
+ primary_header["READPERIOD"] = (self.cycle_time + INT_SYNC_TIMING_OFFSET, "Time between frames [s] "
505
+ "(internal sync)")
506
+ texp_cmd = self.cycle_time - self.readout_time
507
+ primary_header["TEXP_CMD"] = (texp_cmd, "Commanded exposure time [s] (internal sync)")
508
+ primary_header["TEXP_EFF"] = (texp_cmd + INT_SYNC_TIMING_OFFSET, "Effective exposure time [s] (internal "
509
+ "sync)")
510
+
511
+ primary_header["CGSE"] = (self.cgse_version, "Version of the Common EGSE")
512
+
513
+ LOGGER.info(f"Obsid in FITS persistence layer: {self.obsid}")
514
+
515
+ if self.obsid is not None:
516
+
517
+ LOGGER.debug(f"{self.obsid = }")
518
+
519
+ primary_header["OBSID"] = (self.obsid, "Observation identifier")
520
+
521
+ primary_header["PRE_SC_N"] = (CCD_SETTINGS.LENGTH_SERIAL_PRESCAN,
522
+ "Number of pixels/columns in the serial pre-scan")
523
+ primary_header["OVR_SC_N"] = (max(0, self.h_end + 1
524
+ - CCD_SETTINGS.LENGTH_SERIAL_PRESCAN - CCD_SETTINGS.NUM_COLUMNS // 2),
525
+ "Number of virtual pixels / columns in the serial over-scan")
526
+ primary_header["OVR_SC_R"] = (max(0, self.v_end + 1 - CCD_SETTINGS.NUM_ROWS),
527
+ "Number of rows in the parallel over-scan")
528
+ primary_header["IMG_REPR"] = ("FOV_IMG", "Right CCD side flipped w.r.t. readout direction")
529
+
530
+ return primary_header
531
+
532
+ def create_base_image_wcs(self):
533
+ """ Create a basic FITS header for the image.
534
+
535
+ Not all information can be filled out at this point (i.c. extension, CCD identifier, number of columns,
536
+ rotation, reference pixel). This will be done at the moment the serial pre-scan will be written to file.
537
+
538
+ Note that, if both CCD sides will be transmitted, the image of both sides will be glued together (the E-side
539
+ must be flipped horizontally first). If only the E-side will be transmitted, its image must be flipped
540
+ horizontally.
541
+
542
+ Returns: Basic FITS header for the image.
543
+ """
544
+
545
+ image_header = fits.ImageHDU().header
546
+
547
+ # Make sure the data is saved as 16-bit
548
+
549
+ image_header["BITPIX"] = 16
550
+ image_header["BZERO"] = 32768
551
+
552
+ # Dimensionality
553
+
554
+ num_rows = min(CCD_SETTINGS["NUM_ROWS"] - 1, self.v_end) - self.v_start + 1
555
+
556
+ image_header["NAXIS"] = (2, "Dimensionality of the image",)
557
+ image_header["NAXIS2"] = (num_rows, "Number of rows in the image",)
558
+
559
+
560
+ # Focal length (this is needed for the conversion to field angles)
561
+
562
+ image_header["FOCALLEN"] = (FOV_SETTINGS["FOCAL_LENGTH"], "Focal length [mm]",) # TODO
563
+
564
+ # Linear coordinate transformation from sub-field to focal-plane coordinates
565
+
566
+ image_header["ctype1"] = ("LINEAR", "Linear coordinate transformation",)
567
+ image_header["ctype2"] = ("LINEAR", "Linear coordinate transformation",)
568
+
569
+ # Focal-plane coordinates are expressed in mm
570
+
571
+ image_header["CUNIT1"] = ("MM", "Target unit in the column direction (mm)",)
572
+ image_header["CUNIT2"] = ("MM", "Target unit in the row direction (mm)",)
573
+
574
+ # Pixel size
575
+
576
+ cdelt = CCD_SETTINGS["PIXEL_SIZE"] / 1000.0 # Pixel size [mm]
577
+ image_header["CDELT1"] = (cdelt, "Pixel size in the x-direction [mm]",)
578
+ image_header["CDELT2"] = (cdelt, "Pixel size in the y-direction [mm]",)
579
+
580
+ # Additional keywords
581
+
582
+ # image_header["TELESCOP"] = (setup["camera_id"], "Camera ID")
583
+ # image_header["INSTRUME"] = (setup["camera_id"], "Camera ID")
584
+
585
+ image_header["TELESCOP"] = "PLATO"
586
+ if self.camera_id:
587
+ image_header["INSTRUME"] = (self.camera_id, "Camera ID")
588
+ image_header["SITENAME"] = (self.site_name, "Name of the test site")
589
+ image_header["SETUP"] = (self.setup_id, "Setup ID")
590
+ if self.obsid is not None:
591
+ image_header["OBSID"] = (self.obsid, "Observation identifier")
592
+
593
+ return image_header
594
+
595
+ def create_base_serial_prescan_wcs(self):
596
+ """ Create a basic FITS header for the serial pre-scan.
597
+
598
+ Not all information can be filled out at this point (i.c. extension, CCD identifier). This will be
599
+ done at the moment the serial pre-scan will be written to file.
600
+
601
+ Note that, if both CCD sides will be transmitted, you will end up having to serial pre-scan maps per exposures
602
+ (one for each side).
603
+
604
+ Returns: Basic FITS header for the serial pre-scan.
605
+ """
606
+
607
+ serial_prescan_wcs = fits.ImageHDU().header
608
+
609
+ # Make sure the data is saved as 16-bit
610
+
611
+ serial_prescan_wcs["BITPIX"] = 16
612
+ serial_prescan_wcs["BZERO"] = 32768
613
+
614
+ # Dimensionality
615
+
616
+ num_rows = self.v_end - self.v_start + 1
617
+ num_columns = CCD_SETTINGS.LENGTH_SERIAL_PRESCAN
618
+
619
+ serial_prescan_wcs["NAXIS"] = (2, "Dimensionality of the serial pre-scan",)
620
+ serial_prescan_wcs["NAXIS1"] = (num_columns, "Number of columns in the serial pre-scan",)
621
+ serial_prescan_wcs["NAXIS2"] = (num_rows, "Number of rows in the serial pre-scan",)
622
+
623
+ serial_prescan_wcs["TELESCOP"] = "PLATO"
624
+ if self.camera_id:
625
+ serial_prescan_wcs["INSTRUME"] = (self.camera_id, "Camera ID")
626
+ serial_prescan_wcs["SITENAME"] = (self.site_name, "Name of the test site")
627
+ serial_prescan_wcs["SETUP"] = (self.setup_id, "Setup ID")
628
+ if self.obsid is not None:
629
+ serial_prescan_wcs["OBSID"] = (self.obsid, "Observation identifier")
630
+
631
+ return serial_prescan_wcs
632
+
633
+ def create_base_serial_overscan_wcs(self):
634
+ """ Create a basic FITS header for the serial over-scan.
635
+
636
+ Not all information can be filled out at this point (i.c. extension, CCD identifier). This will be
637
+ done at the moment the serial over-scan will be written to file.
638
+
639
+ Note that, if both CCD sides will be transmitted, you will end up having to serial over-scan maps per exposures
640
+ (one for each side).
641
+
642
+ Returns: Basic FITS header for the serial over-scan.
643
+ """
644
+
645
+ serial_overscan_wcs = fits.ImageHDU().header
646
+
647
+ # Make sure the data is saved as 16-bit
648
+
649
+ serial_overscan_wcs["BITPIX"] = 16
650
+ serial_overscan_wcs["BZERO"] = 32768
651
+
652
+ # Dimensionality
653
+
654
+ num_rows = self.v_end - self.v_start + 1
655
+ num_columns = self.v_end + 1 - CCD_SETTINGS.LENGTH_SERIAL_PRESCAN - CCD_SETTINGS.NUM_COLUMNS / 2
656
+
657
+ serial_overscan_wcs["NAXIS"] = (2, "Dimensionality of the serial over-scan",)
658
+ serial_overscan_wcs["NAXIS1"] = (num_columns, "Number of columns in the serial over-scan",)
659
+ serial_overscan_wcs["NAXIS2"] = (num_rows, "Number of rows in the serial over-scan",)
660
+
661
+ # Site name
662
+
663
+ serial_overscan_wcs["TELESCOP"] = "PLATO"
664
+ if self.camera_id:
665
+ serial_overscan_wcs["INSTRUME"] = (self.camera_id, "Camera ID")
666
+ serial_overscan_wcs["SITENAME"] = (self.site_name, "Name of the test site")
667
+ serial_overscan_wcs["SETUP"] = (self.setup_id, "Setup ID")
668
+ if self.obsid is not None:
669
+ serial_overscan_wcs["OBSID"] = (self.obsid, "Observation identifier")
670
+
671
+ return serial_overscan_wcs
672
+
673
+ def create_base_parallel_overscan_wcs(self):
674
+ """ Create a basic FITS header for the parallel over-scan.
675
+
676
+ Not all information can be filled out at this point (i.c. extension, CCD identifier, number of columns). This
677
+ will be done at the moment the parallel over-scan will be written to file.
678
+
679
+ Note that, if both CCD sides will be transmitted, the parallel over-scan of both sides will be glued together
680
+ (the E-side must be flipped horizontally first). If only the E-side will be transmitted, its parallel
681
+ over-scan must be flipped horizontally.
682
+
683
+ Returns: Basic FITS header for the parallel over-scan.
684
+ """
685
+
686
+ parallel_overscan_wcs = fits.ImageHDU().header
687
+
688
+ # Make sure the data is saved as 16-bit
689
+
690
+ parallel_overscan_wcs["BITPIX"] = 16
691
+ parallel_overscan_wcs["BZERO"] = 32768
692
+
693
+ # Dimensionality
694
+
695
+ num_rows_parallel_overscan = max(0, self.v_end - CCD_SETTINGS.NUM_ROWS + 1)
696
+
697
+ parallel_overscan_wcs["NAXIS"] = (2, "Dimensionality of the parallel over-scan",)
698
+ parallel_overscan_wcs["NAXIS2"] = (num_rows_parallel_overscan, "Number of rows in the parallel over-scan",)
699
+
700
+ # Site name
701
+
702
+ parallel_overscan_wcs["TELESCOP"] = "PLATO"
703
+ if self.camera_id:
704
+ parallel_overscan_wcs["INSTRUME"] = (self.camera_id, "Camera ID")
705
+ parallel_overscan_wcs["SITENAME"] = (self.site_name, "Name of the test site")
706
+ parallel_overscan_wcs["SETUP"] = (self.setup_id, "Setup ID")
707
+ if self.obsid is not None:
708
+ parallel_overscan_wcs["OBSID"] = (self.obsid, "Observation identifier")
709
+
710
+ return parallel_overscan_wcs
711
+
712
+ def open(self, mode=None):
713
+
714
+ primary_header = self.create_primary_header()
715
+
716
+ # The primary HDU contains only this header and no image data
717
+
718
+ primary_hdu = fits.PrimaryHDU()
719
+ primary_hdu.header = primary_header
720
+
721
+ # The FITS file is created. If the filename is already in use, and exception
722
+ # will be thrown.
723
+
724
+ primary_hdu.writeto(self._filepath)
725
+
726
+ self.is_fits_file_open = True
727
+
728
+ def close(self):
729
+ """Closes the resource."""
730
+
731
+ self.is_fits_file_open = False
732
+
733
+ def exists(self):
734
+
735
+ return self._filepath.exists()
736
+
737
+ def create(self, data: dict):
738
+ """Add the given data to the FITS file.
739
+
740
+ The given data is a stream of SpW packets, only part of which contains information should go in the FITS file:
741
+
742
+ - Image (and over-scan) data contain the transmitted readout of the CCD(s);
743
+ - Timecode and housekeeping packets are not needed for this purpose.
744
+ """
745
+
746
+ for key, spw_packet in data.items():
747
+
748
+ # if isinstance(spw_packet, TimecodePacket):
749
+ #
750
+ # self.timestamp = spw_packet.
751
+
752
+ if key == "Timestamp":
753
+
754
+ self.timestamp = spw_packet
755
+ self.finetime = time_since_epoch_1958(self.timestamp)
756
+
757
+ elif isinstance(spw_packet, DataPacket):
758
+
759
+ try:
760
+ ccd_bin_to_id = self.setup.camera.fee.ccd_numbering.CCD_BIN_TO_ID
761
+ except AttributeError:
762
+ raise SetupError("No entry in the setup for camera.fee.ccd_numbering.CCD_BIN_TO_ID")
763
+ spw_packet_data_type = spw_packet.type
764
+ ccd_number = ccd_bin_to_id[spw_packet_data_type.ccd_number] # 1-4
765
+ ccd_side = self.fee_side(spw_packet_data_type.ccd_side)
766
+ data_array = spw_packet.data_as_ndarray
767
+
768
+ self.data_arrays[ccd_side][ccd_number] = np.append(self.data_arrays[ccd_side][ccd_number], data_array)
769
+
770
+ # If all data has been received for the current exposure of this CCD, the following steps must be
771
+ # performed:
772
+ # - re-shape the 1D data arrays to 2D data arrays;
773
+ # - extract the different pieces of the 2D data arrays (image + scan maps);
774
+ # - for the F-side: flip the image and parallel over-scan horizontally;
775
+ # - if both CCD sides are transmitted, stitch them together (for the image and parallel
776
+ # over-scan);
777
+ # - update the WCS of the different regions;
778
+ # - add to the FITS file.
779
+
780
+ if self.is_all_data_received(spw_packet_data_type, ccd_number, ccd_side):
781
+
782
+ # Write the information to FITS
783
+
784
+ for ccd_side in self.fee_side:
785
+
786
+ try:
787
+ if self.data_arrays[ccd_side][ccd_number].size > 0:
788
+ self.assemble_slice(ccd_number, ccd_side)
789
+ self.frame_number[ccd_number][ccd_side] += 1
790
+ except MissingSpWPacketsError as exc:
791
+ LOGGER.info(exc)
792
+
793
+ # Get ready for the next exposure
794
+
795
+ self.clear_for_next_exposure(ccd_number, ccd_side)
796
+
797
+ def is_all_data_received(self, spw_packet_data_type: DataPacketType, ccd_number: int, ccd_side):
798
+ """ Check if all data has been received for the current exposure.
799
+
800
+ Args:
801
+ - spw_packet_data_type: Last received data packet type.
802
+ - ccd_number: CCD from which the last received data originates (1-4).
803
+ - ccd_side: CCD side from which the last received data originates.
804
+
805
+ Returns: True if all data for the current exposure has been received; False otherwise.
806
+ """
807
+
808
+ from egse.dpu import got_all_last_packets
809
+
810
+ if spw_packet_data_type.last_packet:
811
+
812
+ packet_type = spw_packet_data_type.packet_type
813
+
814
+ if packet_type == PacketType.DATA_PACKET:
815
+
816
+ if ccd_side == self.fee_side.E_SIDE:
817
+
818
+ self.received_last_packet_flags[ccd_number][0] = True
819
+
820
+ elif ccd_side == self.fee_side.F_SIDE:
821
+
822
+ self.received_last_packet_flags[ccd_number][1] = True
823
+
824
+ elif packet_type == PacketType.OVERSCAN_DATA:
825
+
826
+ if ccd_side == self.fee_side.E_SIDE:
827
+
828
+ self.received_last_packet_flags[ccd_number][2] = True
829
+
830
+ elif ccd_side == self.fee_side.F_SIDE:
831
+
832
+ self.received_last_packet_flags[ccd_number][3] = True
833
+
834
+ return got_all_last_packets(self.received_last_packet_flags[ccd_number], self.expected_last_packet_flags)
835
+
836
+ def got_all_last_packets(self, ccd_number: int, ccd_side):
837
+ """ Check whether all the expected last-packet flags have been seen for the given CCD side.
838
+
839
+ Args:
840
+ - ccd_number: CCD from which the last received data originates (1-4).
841
+ - ccd_side: CCD side from which the last received data originates.
842
+
843
+ Returns: True if the actual and the expected last-packet flags match for the given CCD side of the given CCD;
844
+ False otherwise.
845
+ """
846
+
847
+ received_last_packet_flags = self.received_last_packet_flags[ccd_number]
848
+
849
+ if ccd_side == self.fee_side.E_SIDE:
850
+
851
+ return received_last_packet_flags[0] == self.expected_last_packet_flags[0] \
852
+ and received_last_packet_flags[2] == self.expected_last_packet_flags[2]
853
+
854
+ elif ccd_side == self.fee_side.F_SIDE:
855
+
856
+ return received_last_packet_flags[1] == self.expected_last_packet_flags[1] \
857
+ and received_last_packet_flags[3] == self.expected_last_packet_flags[3]
858
+
859
+ return False
860
+
861
+ def assemble_slice(self, ccd_number: int, ccd_side):
862
+ """ Assemble the data for the given CCD and write it to FITS.
863
+
864
+ Args:
865
+ - ccd_number: CCD identifier (1/2/3/4).
866
+ """
867
+
868
+ # Windowing (pattern) mode
869
+
870
+ if self.is_windowing_mode:
871
+
872
+ # self.assemble_slice_windowing_mode(ccd_number, ccd_side)
873
+ pass
874
+
875
+ # Full-image (pattern) mode
876
+
877
+ else:
878
+
879
+ self.assemble_slice_full_image_mode(ccd_number, ccd_side)
880
+
881
+ # def assemble_slice_windowing_mode(self, ccd_number: int):
882
+ # """ Assemble the data for the given CCD and write it to FITS, for windowing mode or windowing pattern mode.
883
+ #
884
+ # This consists of the following steps:
885
+ #
886
+ # - Create a 1D array, filled with NaNs, exactly big enough to fit the image and scan maps;
887
+ # - Insert the data that was acquired (i.e. coming from the windows);
888
+ # - Convert the 1D data arrays to a 2D data array, in which the image and the scan maps are still glued
889
+ # together (do this for each transmitted CCD side);
890
+ # - Extract for each transmitted CCD side the different regions from the 2D array (image, serial pre-scan,
891
+ # serial over-scan, and parallel over-scan);
892
+ # - Append the serial pre-scan of the transmitted CCD side(s) to the FITS file (after completing its header);
893
+ # - Append the serial over-scan of the transmitted CCD side(s) to the FITS file (after completing its header),
894
+ # if present;
895
+ # - In case the F-side is transmitted, flips its image and parallel over-scan horizontally;
896
+ # - In case both sides are transmitted, stitch the two sides together for the image and the parallel
897
+ # over-scan;
898
+ # - Append the parallel over-scan to the FITS file (after completing its header), if present;
899
+ # - Append the image to the FITS file (after completing its header).
900
+ #
901
+ # Args:
902
+ # - ccd_number: CCD identifier (1/2/3/4).
903
+ # """
904
+ #
905
+ # num_rows = self.v_end - self.v_start + 1
906
+ # num_columns = self.h_end + 1
907
+ #
908
+ # num_rows_image = min(CCD_SETTINGS.NUM_ROWS - 1, self.v_end) - self.v_start + 1
909
+ # num_rows_parallel_overscan = max(0, num_rows - num_rows_image)
910
+ #
911
+ # image = np.array([]).reshape(num_rows_image, 0)
912
+ # parallel_overscan = np.array([]).reshape(num_rows_parallel_overscan, 0)
913
+ #
914
+ # for ccd_side in fee_side:
915
+ #
916
+ # if ccd_side in self.data_arrays:
917
+ #
918
+ # data_array = np.array([float("nan")] * (num_rows * num_columns))
919
+ # data_array[self.windows[ccd_side][ccd_number]] = self.data_arrays[ccd_side][ccd_number]
920
+ #
921
+ # side_image, side_serial_prescan, side_serial_overscan, side_parallel_overscan = \
922
+ # self.extract_full_image_mode(data_array)
923
+ #
924
+ # # Serial pre-scan
925
+ #
926
+ # self.append_serial_prescan(side_serial_prescan, ccd_number, ccd_side)
927
+ #
928
+ # # Serial over-scan
929
+ #
930
+ # if self.has_serial_overscan:
931
+ #
932
+ # self.append_serial_overscan(side_serial_overscan, ccd_number, ccd_side)
933
+ #
934
+ # # For the F-side, the image and parallel over-scan must be flipped horizontally
935
+ #
936
+ # if ccd_side == fee_side.F_SIDE:
937
+ #
938
+ # side_image, side_parallel_overscan = self.flip_f_side_full_image_mode(side_image,
939
+ # side_parallel_overscan)
940
+ #
941
+ # # Image (the part that is on this side)
942
+ #
943
+ # image = np.hstack([image, side_image])
944
+ #
945
+ # # Parallel over-scan (the part that is on this side)
946
+ #
947
+ # if self.has_parallel_overscan:
948
+ #
949
+ # parallel_overscan = np.hstack([parallel_overscan, side_parallel_overscan])
950
+ #
951
+ # # Parallel over-scan
952
+ #
953
+ # if self.has_parallel_overscan:
954
+ #
955
+ # self.append_parallel_overscan(parallel_overscan, ccd_number)
956
+ #
957
+ # # Image
958
+ #
959
+ # self.append_image(image, ccd_number)
960
+
961
+ def assemble_slice_full_image_mode(self, ccd_number: int, ccd_side):
962
+ """ Assemble the data for the given CCD and write it to FITS, for full-image mode or full-image pattern mode.
963
+
964
+ This consists of the following steps:
965
+
966
+ - Convert the 1D data arrays to a 2D data array, in which the image and the scan maps are still glued
967
+ together (do this for each transmitted CCD side);
968
+ - Extract for each transmitted CCD side the different regions from the 2D array (image, serial pre-scan,
969
+ serial over-scan, and parallel over-scan);
970
+ - Append the serial pre-scan of the transmitted CCD side(s) to the FITS file (after completing its header);
971
+ - Append the serial over-scan of the transmitted CCD side(s) to the FITS file (after completing its header),
972
+ if present;
973
+ - In case the F-side is transmitted, flips its image and parallel over-scan horizontally;
974
+ - In case both sides are transmitted, stitch the two sides together for the image and the parallel
975
+ over-scan;
976
+ - Append the parallel over-scan to the FITS file (after completing its header), if present;
977
+ - Append the image to the FITS file (after completing its header).
978
+
979
+ Args:
980
+ - ccd_number: CCD identifier (1/2/3/4).
981
+ """
982
+
983
+ num_rows = self.v_end - self.v_start + 1
984
+ num_columns = self.h_end + 1
985
+
986
+ # Re-shape the 1D array to a 2D array and extract the image and scan maps
987
+
988
+ try:
989
+ data_array = np.reshape(self.data_arrays[ccd_side][ccd_number], (num_rows, num_columns)) # 1D -> 2D
990
+ except ValueError as exc:
991
+ raise MissingSpWPacketsError(f"Incomplete SpW data for frame {self.frame_number[ccd_number][ccd_side]} "
992
+ f"for the {ccd_side.name[0]}-side off CCD{ccd_number}. Check the DPU "
993
+ f"Processing logs for more information.") from exc
994
+ side_image, side_serial_prescan, side_serial_overscan, side_parallel_overscan = \
995
+ self.extract_full_image_mode(data_array)
996
+
997
+ # Serial pre-scan
998
+
999
+ self.append_serial_prescan(side_serial_prescan, ccd_number, ccd_side)
1000
+
1001
+ # Serial over-scan
1002
+
1003
+ if self.has_serial_overscan:
1004
+
1005
+ self.append_serial_overscan(side_serial_overscan, ccd_number, ccd_side)
1006
+
1007
+ # Parallel over-scan
1008
+
1009
+ if self.has_parallel_overscan:
1010
+
1011
+ self.append_parallel_overscan(side_parallel_overscan, ccd_number, ccd_side)
1012
+
1013
+ # Image
1014
+
1015
+ self.append_image(side_image, ccd_number, ccd_side)
1016
+
1017
+ def extract_full_image_mode(self, data_array):
1018
+ """Extract the image and scan maps from the given data array.
1019
+
1020
+ For the F-side, the image and parallel over-scan still have to be flipped horizontally.
1021
+
1022
+ Args:
1023
+ - data_array: 2D array in which the image data and scan maps are still glued together.
1024
+
1025
+ Returns:
1026
+ - Image data;
1027
+ - Serial pre-scan;
1028
+ - Serial over-scan;
1029
+ - Parallel over-scan.
1030
+ """
1031
+
1032
+ # Calculate the following indices:
1033
+ # - Column index of the first transmitted image column;
1034
+ # - Column index of the last transmitted image column + 1 (if the serial over-scan is transmitted, this will
1035
+ # be the column index of the first transmitted serial over-scan column);
1036
+ # - Row index of the last transmitted image row + 1 (if the parallel over-scan is transmitted, this will be
1037
+ # the row index of the first transmitted parallel over-scan row).
1038
+
1039
+ start_column_image = CCD_SETTINGS.LENGTH_SERIAL_PRESCAN
1040
+ end_column_image_plus_1 = min(CCD_SETTINGS.LENGTH_SERIAL_PRESCAN + CCD_SETTINGS.NUM_COLUMNS // 2, self.h_end + 1)
1041
+ end_row_image_plus_1 = min(CCD_SETTINGS.NUM_ROWS - 1, self.v_end) - self.v_start + 1
1042
+
1043
+ # Serial pre-scan (all rows, but only the first couple of rows)
1044
+
1045
+ serial_prescan = data_array[:, 0: start_column_image]
1046
+
1047
+ # Serial over-scan (all rows, omit the columns from the serial pre-scan and the serial over-scan)
1048
+
1049
+ if self.has_serial_overscan:
1050
+
1051
+ serial_overscan = data_array[:, end_column_image_plus_1:]
1052
+
1053
+ else:
1054
+
1055
+ serial_overscan = None
1056
+
1057
+ # Image (omit the rows from the parallel over-scan
1058
+ # and the columns from the serial pre-scan and the serial over-scan)
1059
+
1060
+ image = data_array[0: end_row_image_plus_1, start_column_image: end_column_image_plus_1]
1061
+
1062
+ # Parallel over-scan
1063
+
1064
+ if self.has_parallel_overscan:
1065
+
1066
+ parallel_overscan = data_array[end_row_image_plus_1:, start_column_image: end_column_image_plus_1]
1067
+
1068
+ else:
1069
+
1070
+ parallel_overscan = None
1071
+
1072
+ return image, serial_prescan, serial_overscan, parallel_overscan
1073
+
1074
+ def append_serial_prescan(self, serial_prescan, ccd_number: int, ccd_side: str):
1075
+ """ Append the given serial pre-scan to the FITS file (after completing its header).
1076
+
1077
+ Args:
1078
+ - serial_prescan: Serial pre-scan.
1079
+ - ccd_number: CCD identifier.
1080
+ - ccd_side: CCD side.
1081
+ """
1082
+
1083
+ extension = f"SPRE_{ccd_number}_{self.fee_side(ccd_side).name[0]}"
1084
+ self.serial_prescan_header["EXTNAME"] = extension
1085
+ self.serial_prescan_header["EXTVER"] = self.frame_number[ccd_number][ccd_side]
1086
+ self.serial_prescan_header["CCD_ID"] = (ccd_number, "CCD identifier",)
1087
+ self.serial_prescan_header["SENSOR_SEL"] = (self.fee_side(ccd_side).name[0], "CCD side")
1088
+ self.serial_prescan_header["DATE-OBS"] = (self.timestamp, "Timestamp for 1st frame",)
1089
+ self.serial_prescan_header["FINETIME"] = (self.finetime, "Finetime representation of DATE-OBS",)
1090
+
1091
+ if ccd_side in [self.fee_side.RIGHT_SIDE.name, self.fee_side.RIGHT_SIDE]:
1092
+ serial_prescan = np.fliplr(serial_prescan)
1093
+
1094
+ fits.append(str(self._filepath), serial_prescan, self.serial_prescan_header)
1095
+
1096
+ def append_serial_overscan(self, serial_overscan, ccd_number: int, ccd_side: str):
1097
+ """ Append the given serial over-scan to the FITS file (after completing its header).
1098
+
1099
+ Args:
1100
+ - serial_overscan: Serial over-scan.
1101
+ - ccd_number: CCD identifier.
1102
+ - ccd_side: CCD side.
1103
+ """
1104
+
1105
+ extension = f"SOVER_{ccd_number}_{self.fee_side(ccd_side).name[0]}"
1106
+ self.serial_overscan_header["EXTNAME"] = extension
1107
+ self.serial_overscan_header["EXTVER"] = self.frame_number[ccd_number][ccd_side]
1108
+ self.serial_overscan_header["CCD_ID"] = (ccd_number, "CCD identifier",)
1109
+ self.serial_overscan_header["SENSOR_SEL"] = (self.fee_side(ccd_side).name[0], "CCD side")
1110
+ self.serial_overscan_header["NAXIS1"] = (serial_overscan.shape[1], "Number of columns in the serial over-scan",)
1111
+ self.serial_overscan_header["DATE-OBS"] = (self.timestamp, "Timestamp for 1st frame",)
1112
+ self.serial_overscan_header["FINETIME"] = (self.finetime, "Finetime representation of DATE-OBS")
1113
+
1114
+ if ccd_side in [self.fee_side.RIGHT_SIDE.name, self.fee_side.RIGHT_SIDE]:
1115
+ serial_overscan = np.fliplr(serial_overscan)
1116
+
1117
+ fits.append(str(self._filepath), serial_overscan, self.serial_overscan_header)
1118
+
1119
+ def append_parallel_overscan(self, parallel_overscan, ccd_number: int, ccd_side: str):
1120
+ """ Append the given parallel over-scan to the FITS file (after completing its header).
1121
+
1122
+ Args:
1123
+ - parallel_overscan: Parallel over-scan.
1124
+ - ccd_number: CCD identifier.
1125
+ - ccd_side: CCD side.
1126
+ """
1127
+
1128
+ extension = f"POVER_{ccd_number}_{self.fee_side(ccd_side).name[0]}"
1129
+ self.parallel_overscan_header["EXTNAME"] = extension
1130
+ self.parallel_overscan_header["EXTVER"] = self.frame_number[ccd_number][ccd_side]
1131
+ self.parallel_overscan_header["CCD_ID"] = (ccd_number, "CCD identifier",)
1132
+ self.parallel_overscan_header["SENSOR_SEL"] = (self.fee_side(ccd_side).name[0], "CCD side")
1133
+ self.parallel_overscan_header["NAXIS1"] = (parallel_overscan.shape[1],
1134
+ "Number of columns in the parallel over-scan",)
1135
+ self.parallel_overscan_header["DATE-OBS"] = (self.timestamp, "Timestamp for 1st frame",)
1136
+ self.parallel_overscan_header["FINETIME"] = (self.finetime, "Finetime representation of DATE-OBS",)
1137
+
1138
+ if ccd_side in [self.fee_side.RIGHT_SIDE.name, self.fee_side.RIGHT_SIDE]:
1139
+ parallel_overscan = np.fliplr(parallel_overscan)
1140
+
1141
+ fits.append(str(self._filepath), parallel_overscan, self.parallel_overscan_header)
1142
+
1143
+ def append_image(self, image, ccd_number: int, ccd_side: str):
1144
+ """ Append the given image to the FITS file (after completing its header).
1145
+
1146
+ Args:
1147
+ - image: Image.
1148
+ - ccd_number: CCD identifier (1/2/3/4).
1149
+ - ccd_side: CCD side.
1150
+ """
1151
+
1152
+ extension = f"IMAGE_{ccd_number}_{self.fee_side(ccd_side).name[0]}"
1153
+ self.image_header["EXTNAME"] = extension
1154
+ self.image_header["EXTVER"] = self.frame_number[ccd_number][ccd_side]
1155
+ self.image_header["CCD_ID"] = (ccd_number, "CCD identifier",)
1156
+ self.image_header["SENSOR_SEL"] = (self.fee_side(ccd_side).name[0], "CCD side")
1157
+ self.image_header["NAXIS1"] = (image.shape[1], "Number of columns in the image",)
1158
+
1159
+ ccd_orientation_degrees = CCD_SETTINGS.ORIENTATION[ccd_number - 1]
1160
+ ccd_orientation_radians = radians(ccd_orientation_degrees)
1161
+
1162
+ self.image_header["CROTA2"] = (ccd_orientation_degrees, "CCD orientation angle [degrees]",)
1163
+
1164
+ cdelt = CCD_SETTINGS["PIXEL_SIZE"] / 1000.0 # Pixel size [mm]
1165
+
1166
+ self.image_header["CD1_1"] = (cdelt * cos(ccd_orientation_radians),
1167
+ "Pixel size x cos(CCD orientation angle)",)
1168
+ self.image_header["CD1_2"] = (-cdelt * sin(ccd_orientation_radians),
1169
+ "-Pixel size x sin(CCD orientation angle)",)
1170
+ self.image_header["CD2_1"] = (cdelt * sin(ccd_orientation_radians),
1171
+ "Pixel size x sin(CCD orientation angle)",)
1172
+ self.image_header["CD2_2"] = (cdelt * cos(ccd_orientation_radians),
1173
+ "Pixel size x cos(CCD orientation angle)",)
1174
+
1175
+ zeropoint_x, zeropoint_y = -np.array(CCD_SETTINGS.ZEROPOINT)
1176
+ crval1 = zeropoint_x * cos(ccd_orientation_radians) - zeropoint_y * sin(ccd_orientation_radians)
1177
+ crval2 = zeropoint_x * sin(ccd_orientation_radians) + zeropoint_y * cos(ccd_orientation_radians)
1178
+
1179
+ self.image_header["CRVAL1"] = (crval1, "FP x-coordinate of the CCD origin [mm]",)
1180
+ self.image_header["CRVAL2"] = (crval2, "FP y-coordinate of the CCD origin [mm]",)
1181
+
1182
+ self.image_header["CRPIX2"] = (-self.v_start,
1183
+ "CCD origin row wrt 1st transmitted row",)
1184
+
1185
+ if ccd_side in [self.fee_side.LEFT_SIDE.name, self.fee_side.LEFT_SIDE]:
1186
+ self.image_header["CRPIX1"] = (0, "CCD origin column wrt lower left corner",)
1187
+
1188
+ if ccd_side in [self.fee_side.RIGHT_SIDE.name, self.fee_side.RIGHT_SIDE]:
1189
+ image = np.fliplr(image)
1190
+ self.image_header["CRPIX1"] = (-CCD_SETTINGS.NUM_ROWS + image.shape[1],
1191
+ "CCD origin column wrt lower left corner")
1192
+
1193
+ self.image_header["DATE-OBS"] = (self.timestamp, "Timestamp for 1st frame",)
1194
+ self.image_header["FINETIME"] = (self.finetime, "Finetime representation of DATE-OBS",)
1195
+
1196
+ fits.append(str(self._filepath), image, self.image_header)
1197
+
1198
+ def read(self, select=None):
1199
+ """Returns a list of all entries in the persistence store.
1200
+
1201
+ The list can be filtered based on a selection from the `select` argument which
1202
+ should be a Callable object.
1203
+
1204
+ Args:
1205
+ - select (Callable): a filter function to narrow down the list of all entries.
1206
+
1207
+ Returns: List or generator for all entries in the persistence store.
1208
+ """
1209
+ raise NotImplementedError("Persistence layers must implement a read method")
1210
+
1211
+ def update(self, idx, data):
1212
+
1213
+ pass
1214
+
1215
+ def delete(self, idx):
1216
+
1217
+ LOGGER.warning("The delete functionality is not implemented for the CSV persistence layer.")
1218
+
1219
+ def get_filepath(self):
1220
+
1221
+ return self._filepath
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: plato-fits
3
+ Version: 2024.1.4
4
+ Summary: FITS Persistence implementation for CGSE
5
+ Author: IVS KU Leuven
6
+ Maintainer-email: Rik Huygen <rik.huygen@kuleuven.be>, Sara Regibo <sara.regibo@kuleuven.be>
7
+ License-Expression: MIT
8
+ Keywords: CGSE,Common-EGSE,hardware testing,software framework
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: astropy>=6.0.1
11
+ Requires-Dist: cgse-common
12
+ Requires-Dist: numpy==1.22.4
13
+ Requires-Dist: plato-spw
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest; extra == 'test'
16
+ Requires-Dist: pytest-cov; extra == 'test'
17
+ Requires-Dist: pytest-mock; extra == 'test'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # FITS plugin for the CGSE storage manager
@@ -0,0 +1,5 @@
1
+ egse/plugins/storage/fits.py,sha256=GlZSXCaFIihCKOj0dALcbzjeBcucxtImKGMtniFIvVk,56994
2
+ plato_fits-2024.1.4.dist-info/METADATA,sha256=acPLE0vaArGKQCu0MH9JeA2_eUe1kpUK6OlO3J9xGDs,674
3
+ plato_fits-2024.1.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
+ plato_fits-2024.1.4.dist-info/entry_points.txt,sha256=q-XB-6IMXSeoxPNRDU-EFHgAyI0K6GzujNAyEdrp3zM,115
5
+ plato_fits-2024.1.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [cgse.storage.persistence]
2
+ FITS = egse.plugins.storage.fits:FITS
3
+
4
+ [cgse.version]
5
+ plato-fits = egse.storage.plugins