2
2
Base class for instrument objects.
3
3
"""
4
4
import astropy .units as u
5
+ import astropy .wcs
5
6
import copy
6
7
import numpy as np
7
8
import pathlib
@@ -45,14 +46,20 @@ class InstrumentBase:
45
46
Coordinate of the observing instrument
46
47
resolution : `~astropy.units.Quantity`
47
48
cadence : `~astropy.units.Quantity`, optional
48
- If specified, this is used to construct the observing time
49
+ If specified, this is used to construct the observing time.
49
50
pad_fov : `~astropy.units.Quantity`, optional
50
51
Two-dimensional array specifying the padding to apply to the field of view of the synthetic
51
- image in both directions. If None, no padding is applied and the field of view is defined
52
- by the maximal extent of the loop coordinates in each direction.
52
+ image in both directions in pixel space. If None, no padding is applied and the field of
53
+ view is defined by the maximal extent of the loop coordinates in each direction.
54
+ Note that if ``fov_center`` and ``fov_width`` are specified, this is ignored.
53
55
fov_center : `~astropy.coordinates.SkyCoord`, optional
56
+ Reference coordinate coinciding with the center of the field of view.
57
+ For this to have an effect, must also specify ``fov_width``.
54
58
fov_width : `~astropy.units.Quantity`, optional
59
+ Extent of the field of view in pixels.
60
+ For this to have an effect, must also specify ``fov_center``.
55
61
average_over_los : `bool`, optional
62
+ Set to true for non-volumetric quantities
56
63
"""
57
64
58
65
@u .quantity_input
@@ -61,9 +68,9 @@ def __init__(self,
61
68
observer ,
62
69
resolution : u .Unit ('arcsec/pix' ),
63
70
cadence : u .s = None ,
64
- pad_fov : u .arcsec = None ,
65
- fov_center = None ,
66
- fov_width : u .arcsec = None ,
71
+ pad_fov : u .pixel = None ,
72
+ fov_center = None ,
73
+ fov_width : u .pixel = None ,
67
74
average_over_los = False ):
68
75
self .observer = observer
69
76
self .cadence = cadence
@@ -111,13 +118,13 @@ def observer(self, value):
111
118
self ._observer = value
112
119
113
120
@property
114
- def pad_fov (self ) -> u .arcsec :
121
+ def pad_fov (self ) -> u .pixel :
115
122
return self ._pad_fov
116
123
117
124
@pad_fov .setter
118
125
def pad_fov (self , value ):
119
126
if value is None :
120
- value = [0 , 0 ] * u .arcsec
127
+ value = [0 , 0 ] * u .pixel
121
128
self ._pad_fov = value
122
129
123
130
@property
@@ -144,21 +151,17 @@ def get_instrument_name(self, channel):
144
151
return self .name
145
152
146
153
def calculate_intensity_kernel (self , * args , ** kwargs ):
147
- """
148
- Converts emissivity for a particular transition to counts per detector channel. When writing
149
- a new instrument class, this method should be overridden.
150
- """
151
- raise NotImplementedError ('No detect method implemented.' )
154
+ raise NotImplementedError
152
155
153
156
@property
154
157
def projected_frame (self ):
155
- return Helioprojective (observer = self .observer , obstime = self . observer . obstime )
158
+ return Helioprojective (observer = self .observer )
156
159
157
160
@property
158
161
@u .quantity_input
159
162
def pixel_area (self ) -> u .cm ** 2 :
160
163
"""
161
- Pixel area
164
+ Cartesian area on the surface of the Sun covered by a single pixel.
162
165
"""
163
166
sa_equiv = solar_angle_equivalency (self .observer )
164
167
res = (1 * u .pix * self .resolution ).to ('cm' , equivalencies = sa_equiv )
@@ -184,18 +187,18 @@ def observe(self, skeleton, save_directory=None, channels=None, **kwargs):
184
187
skeleton : `~synthesizAR.Skeleton`
185
188
save_directory : `str` or path-like
186
189
"""
187
- check_visible = kwargs .pop ('check_visible' , False )
188
190
if channels is None :
189
191
channels = self .channels
190
192
try :
191
193
import distributed
192
194
client = distributed .get_client ()
193
195
except (ImportError , ValueError ):
194
196
client = None
195
- coordinates = skeleton .all_coordinates
197
+ ref_coord , n_pixels = self .get_fov (skeleton .all_coordinates )
198
+ wcs = astropy .wcs .WCS (header = self .get_header (ref_coord , n_pixels ))
196
199
coordinates_centers = skeleton .all_coordinates_centers
197
- bins , bin_range = self . get_detector_array ( coordinates )
198
- coordinates_centers_projected = coordinates_centers .transform_to (self .projected_frame )
200
+ pixel_locations = wcs . world_to_pixel ( coordinates_centers )
201
+ visibilities = coordinates_centers .transform_to (self .projected_frame ). is_visible ( )
199
202
maps = {}
200
203
for channel in channels :
201
204
# Compute intensity as a function of time and field-aligned coordinate
@@ -244,20 +247,19 @@ def observe(self, skeleton, save_directory=None, channels=None, **kwargs):
244
247
kernels_interp = client .gather (kernel_interp_futures )
245
248
kernels = np .concatenate ([u .Quantity (* k ) for k in kernels_interp ], axis = 1 )
246
249
247
- header = self .get_header (channel , coordinates )
250
+ header = self .get_header (ref_coord , n_pixels , channel = channel )
248
251
# Build a map for each timestep
249
252
maps [channel .name ] = []
250
253
for i , time in enumerate (self .observing_time ):
251
254
m = self .integrate_los (
252
255
time ,
253
256
channel ,
254
257
skeleton ,
255
- coordinates_centers_projected ,
256
- bins ,
257
- bin_range ,
258
+ pixel_locations ,
259
+ n_pixels ,
260
+ visibilities ,
258
261
header ,
259
- kernels = kernels [i ],
260
- check_visible = check_visible )
262
+ kernels = kernels [i ])
261
263
m = self .convolve_with_psf (m , channel )
262
264
if save_directory is None :
263
265
maps [channel .name ].append (m )
@@ -363,8 +365,15 @@ def interpolate_to_instrument_time(kernel, loop, observing_time, axis=0):
363
365
kernel_interp = u .Quantity (f_t (observing_time .value ), kernel_unit )
364
366
return kernel_interp
365
367
366
- def integrate_los (self , time , channel , skeleton , coordinates_centers , bins , bin_range , header ,
367
- kernels = None , check_visible = False ):
368
+ def integrate_los (self ,
369
+ time ,
370
+ channel ,
371
+ skeleton ,
372
+ pixel_locations ,
373
+ n_pixels ,
374
+ visibilities ,
375
+ header ,
376
+ kernels = None ):
368
377
# Compute weights
369
378
if kernels is None :
370
379
i_time = np .where (time == self .observing_time )[0 ][0 ]
@@ -376,27 +385,24 @@ def integrate_los(self, time, channel, skeleton, coordinates_centers, bins, bin_
376
385
# average along the LOS
377
386
if not self .average_over_los :
378
387
kernels *= (skeleton .all_cross_sectional_areas / self .pixel_area ).decompose () * skeleton .all_widths
379
- if check_visible :
380
- visible = coordinates_centers .is_visible ()
381
- else :
382
- visible = np .ones (kernels .shape )
383
388
# Bin
384
- blc , trc = bin_range
389
+ # NOTE: Bin order is (y,x) or (row, column)
390
+ bins = n_pixels .to_value ('pixel' )
391
+ bin_edges = (np .linspace (- 0.5 , bins [1 ]- 0.5 , bins [1 ]+ 1 ),
392
+ np .linspace (- 0.5 , bins [0 ]- 0.5 , bins [0 ]+ 1 ))
385
393
hist , _ , _ = np .histogram2d (
386
- coordinates_centers .Tx .value ,
387
- coordinates_centers .Ty .value ,
388
- bins = bins ,
389
- range = ((blc .Tx .value , trc .Tx .value ), (blc .Ty .value , trc .Ty .value )),
390
- weights = kernels .to_value (self ._expected_unit ) * visible ,
394
+ pixel_locations [1 ],
395
+ pixel_locations [0 ],
396
+ bins = bin_edges ,
397
+ weights = kernels .to_value (self ._expected_unit ) * visibilities ,
391
398
)
392
399
# For some quantities, need to average over all components along a given LOS
393
400
if self .average_over_los :
394
401
_hist , _ , _ = np .histogram2d (
395
- coordinates_centers .Tx .value ,
396
- coordinates_centers .Ty .value ,
397
- bins = bins ,
398
- range = ((blc .Tx .value , trc .Tx .value ), (blc .Ty .value , trc .Ty .value )),
399
- weights = visible ,
402
+ pixel_locations [1 ],
403
+ pixel_locations [0 ],
404
+ bins = bin_edges ,
405
+ weights = visibilities ,
400
406
)
401
407
hist /= np .where (_hist == 0 , 1 , _hist )
402
408
# NOTE: Purposefully using a nonstandard key to record this time as we do not
@@ -409,58 +415,59 @@ def integrate_los(self, time, channel, skeleton, coordinates_centers, bins, bin_
409
415
410
416
return Map (hist .T , new_header )
411
417
412
- def get_header (self , channel , coordinates ):
418
+ def get_header (self , ref_coord , n_pixels : u . pixel , channel = None ):
413
419
"""
414
- Create the FITS header for a given channel and set of loop coordinates
415
- that define the needed FOV.
420
+ Create the FITS header for a given channel.
421
+
422
+ Parameters
423
+ ----------
424
+ ref_coord: `~astropy.coordinates.SkyCoord`
425
+ Reference coordinate coincident with the center of the field
426
+ of view
427
+ n_pixels: `~astropy.units.Quantity`
428
+ Pixel extent in the x (horizontal) and y (vertical) direction
429
+ channel: `ChannelBase`, optional
416
430
"""
417
- bins , bin_range = self .get_detector_array (coordinates )
418
- center = SkyCoord (Tx = (bin_range [1 ].Tx + bin_range [0 ].Tx )/ 2 ,
419
- Ty = (bin_range [1 ].Ty + bin_range [0 ].Ty )/ 2 ,
420
- frame = bin_range [0 ].frame )
421
- # FIXME: reference_pixel should be center of the frame in the pixel
422
- # coordinate system of the image.
431
+ # NOTE: channel is a kwarg so that a WCS can be computed without specifying
432
+ # a channel as these keywords do not affect the WCS
433
+ if channel is None :
434
+ instrument = None
435
+ wavelength = None
436
+ else :
437
+ instrument = self .get_instrument_name (channel )
438
+ wavelength = channel .channel
423
439
header = make_fitswcs_header (
424
- ( bins [ 1 ], bins [ 0 ] ), # swap order because it expects (row,column)
425
- center ,
426
- reference_pixel = (u . Quantity ( bins , 'pix' ) - 1 * u .pix ) / 2 , # center of lower left pixel is (0,0)
440
+ n_pixels [:: - 1 ]. to_value ( 'pixel' ), # swap order because it expects (row,column)
441
+ ref_coord ,
442
+ reference_pixel = (n_pixels - 1 * u .pix ) / 2 , # center of lower left pixel is (0,0)
427
443
scale = self .resolution ,
428
444
observatory = self .observatory ,
429
- instrument = self . get_instrument_name ( channel ) ,
445
+ instrument = instrument ,
430
446
telescope = self .telescope ,
431
447
detector = self .detector ,
432
- wavelength = channel . channel ,
448
+ wavelength = wavelength ,
433
449
unit = self ._expected_unit ,
434
450
)
435
451
return header
436
452
437
- def get_detector_array (self , coordinates ):
453
+ def get_fov (self , coordinates ):
438
454
"""
439
- Calculate the number of pixels in the detector FOV and the physical coordinates of the
440
- bottom left and top right corners.
455
+ Return the coordinate at the center of the FOV and the width in pixels.
441
456
"""
442
457
if self .fov_center is not None and self .fov_width is not None :
443
458
center = self .fov_center .transform_to (self .projected_frame )
444
- bins_x = int (np .ceil ((self .fov_width [0 ] / self .resolution [0 ]).decompose ()).value )
445
- bins_y = int (np .ceil ((self .fov_width [1 ] / self .resolution [1 ]).decompose ()).value )
446
- bottom_left_corner = SkyCoord (
447
- Tx = center .Tx - self .fov_width [0 ]/ 2 ,
448
- Ty = center .Ty - self .fov_width [1 ]/ 2 ,
449
- frame = center .frame ,
450
- )
451
- top_right_corner = SkyCoord (
452
- Tx = bottom_left_corner .Tx + self .fov_width [0 ],
453
- Ty = bottom_left_corner .Ty + self .fov_width [1 ],
454
- frame = bottom_left_corner .frame
455
- )
459
+ n_pixels = self .fov_width
456
460
else :
457
461
# If not specified, derive FOV from loop coordinates
458
462
coordinates = coordinates .transform_to (self .projected_frame )
459
- bottom_left_corner , top_right_corner = find_minimum_fov (
460
- coordinates , padding = self .pad_fov ,
461
- )
463
+ bottom_left_corner , top_right_corner = find_minimum_fov (coordinates )
462
464
delta_x = top_right_corner .Tx - bottom_left_corner .Tx
463
465
delta_y = top_right_corner .Ty - bottom_left_corner .Ty
464
- bins_x = int (np .ceil ((delta_x / self .resolution [0 ]).decompose ()).value )
465
- bins_y = int (np .ceil ((delta_y / self .resolution [1 ]).decompose ()).value )
466
- return (bins_x , bins_y ), (bottom_left_corner , top_right_corner )
466
+ center = SkyCoord (Tx = bottom_left_corner .Tx + delta_x / 2 ,
467
+ Ty = bottom_left_corner .Ty + delta_y / 2 ,
468
+ frame = bottom_left_corner .frame )
469
+ pixels_x = int (np .ceil ((delta_x / self .resolution [0 ]).decompose ()).value )
470
+ pixels_y = int (np .ceil ((delta_y / self .resolution [1 ]).decompose ()).value )
471
+ n_pixels = u .Quantity ([pixels_x , pixels_y ], 'pixel' )
472
+ n_pixels += self .pad_fov
473
+ return center , n_pixels
0 commit comments