Skip to content

Commit c06d817

Browse files
authored
Perform coordinate binning in pixel space (#104)
* first pass at pixel space binning * fix inconsistencies in new API * pin to sunpy bugfix * fix examples * cache pixel area * move HGS transform to setter
1 parent 8e3c18d commit c06d817

File tree

10 files changed

+112
-112
lines changed

10 files changed

+112
-112
lines changed

examples/arcade-rtv.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343

4444
#########################################################################
4545
# Finally, compute the LOS integrated AIA emission.
46-
aia = InstrumentSDOAIA([0, 1]*u.s, earth_observer, pad_fov=(20, 20)*u.arcsec)
46+
aia = InstrumentSDOAIA([0, 1]*u.s, earth_observer, pad_fov=(40, 40)*u.pixel)
4747
maps = aia.observe(arcade)
4848

4949
#########################################################################
@@ -56,7 +56,7 @@
5656
# a different LOS.
5757
off_limb_observer = SkyCoord(
5858
lon=-70*u.deg, lat=earth_observer.lat, radius=earth_observer.radius, frame=earth_observer.frame)
59-
aia = InstrumentSDOAIA([0, 1]*u.s, off_limb_observer, pad_fov=(10, 10)*u.arcsec,)
59+
aia = InstrumentSDOAIA([0, 1]*u.s, off_limb_observer, pad_fov=(20, 20)*u.pixel,)
6060
maps = aia.observe(arcade)
6161
for k in maps:
6262
maps[k][0].peek(draw_limb=True)

examples/ebtel-nanoflares.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
# emission from 500 s to 6000 s at a cadence of 50 s for the 193 Å channel.
8989
aia = InstrumentSDOAIA(np.arange(500,6e3,50)*u.s,
9090
sdo_observer,
91-
pad_fov=(20, 20)*u.arcsec)
91+
pad_fov=(40, 40)*u.pixel)
9292
maps = aia.observe(arcade, channels=aia.channels[3:4])
9393

9494
###########################################################################

examples/loop-bundle-rtv.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
###########################################################################
5656
# We can then compute the emission as observed by the 171 channel of AIA
5757
# as viewed from an observer at 1 AU directly above the loop.
58-
aia = InstrumentSDOAIA([0, 1]*u.s, pos, pad_fov=(10, 10)*u.arcsec)
58+
aia = InstrumentSDOAIA([0, 1]*u.s, pos, pad_fov=(40, 40)*u.pixel)
5959
maps = aia.observe(bundle, channels=aia.channels[2:3])
6060
m_171 = maps['171'][0]
6161
m_171.peek()
@@ -92,6 +92,6 @@
9292
###########################################################################
9393
# Finally, we can also compute the AIA 171 intensity as viewed from the
9494
# side in order to see the semi-circular geometry of the loop bundle.
95-
aia = InstrumentSDOAIA([0, 1]*u.s, side_on_view, pad_fov=(10, 10)*u.arcsec)
95+
aia = InstrumentSDOAIA([0, 1]*u.s, side_on_view, pad_fov=(40, 40)*u.pixel)
9696
maps = aia.observe(bundle, channels=aia.channels[2:3])
9797
maps['171'][0].peek()

examples/multi-instrument.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ def get_heating_constant(self, loop):
8787
# We'll select a field of view by specifying the center of the field of view
8888
# as well as the width and height.
8989
center = SkyCoord(Tx=0*u.arcsec, Ty=-550*u.arcsec, frame=Helioprojective(observer=sdo, obstime=sdo.obstime))
90-
aia = InstrumentSDOAIA([0]*u.s, sdo, fov_center=center, fov_width=(250, 250)*u.arcsec)
90+
fov_width = (250,250)*u.arcsec
91+
aia = InstrumentSDOAIA([0]*u.s, sdo, fov_center=center, fov_width=fov_width)
9192
aia_images = aia.observe(skeleton)
9293
for k in aia_images:
9394
aia_images[k][0].peek()
@@ -96,7 +97,7 @@ def get_heating_constant(self, loop):
9697
# We can carry out this same procedure for *Hinode* XRT for the same field of view.
9798
# We'll look just at the Be-thin and Al-poly channels.
9899
xrt = InstrumentHinodeXRT([0]*u.s, hinode, ['Be-thin', 'Al-poly'],
99-
fov_center=center, fov_width=(250, 250)*u.arcsec)
100+
fov_center=center, fov_width=fov_width)
100101
xrt_images = xrt.observe(skeleton)
101102
for k in xrt_images:
102103
xrt_images[k][0].peek()
@@ -138,6 +139,6 @@ def get_instrument_name(self, *args):
138139
# We can then use our custom instrument class in the exact same way as our
139140
# predefined classes to model the emission from EUVI. Note that we'll only do
140141
# this for the 171 Å channel.
141-
euvi = InstrumentSTEREOEUVI([0]*u.s, stereo_a, fov_center=center, fov_width=(250, 250)*u.arcsec)
142+
euvi = InstrumentSTEREOEUVI([0]*u.s, stereo_a, fov_center=center, fov_width=fov_width)
142143
euvi_images = euvi.observe(skeleton, channels=euvi.channels[2:3])
143144
euvi_images['171'][0].peek()

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ requires-python = ">=3.10"
1717
dependencies = [
1818
"scipy",
1919
"matplotlib",
20-
"sunpy[all]>=6.0",
20+
"sunpy[all]>=6.0.3",
2121
"zarr",
2222
"asdf",
2323
"ndcube>=2.0",

synthesizAR/instruments/base.py

+88-80
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Base class for instrument objects.
33
"""
44
import astropy.units as u
5+
import astropy.wcs
56
import copy
67
import numpy as np
78
import pathlib
@@ -10,6 +11,7 @@
1011

1112
from astropy.coordinates import SkyCoord
1213
from dataclasses import dataclass
14+
from functools import cached_property
1315
from scipy.interpolate import interp1d
1416
from scipy.ndimage import gaussian_filter
1517
from sunpy.coordinates import HeliographicStonyhurst, Helioprojective
@@ -45,14 +47,20 @@ class InstrumentBase:
4547
Coordinate of the observing instrument
4648
resolution : `~astropy.units.Quantity`
4749
cadence : `~astropy.units.Quantity`, optional
48-
If specified, this is used to construct the observing time
50+
If specified, this is used to construct the observing time.
4951
pad_fov : `~astropy.units.Quantity`, optional
5052
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.
53+
image in both directions in pixel space. If None, no padding is applied and the field of
54+
view is defined by the maximal extent of the loop coordinates in each direction.
55+
Note that if ``fov_center`` and ``fov_width`` are specified, this is ignored.
5356
fov_center : `~astropy.coordinates.SkyCoord`, optional
57+
Reference coordinate coinciding with the center of the field of view.
58+
For this to have an effect, must also specify ``fov_width``.
5459
fov_width : `~astropy.units.Quantity`, optional
60+
Angular extent of the field of the view.
61+
For this to have an effect, must also specify ``fov_center``.
5562
average_over_los : `bool`, optional
63+
Set to true for non-volumetric quantities
5664
"""
5765

5866
@u.quantity_input
@@ -61,8 +69,8 @@ def __init__(self,
6169
observer,
6270
resolution: u.Unit('arcsec/pix'),
6371
cadence: u.s = None,
64-
pad_fov: u.arcsec = None,
65-
fov_center=None,
72+
pad_fov: u.pixel = None,
73+
fov_center = None,
6674
fov_width: u.arcsec = None,
6775
average_over_los=False):
6876
self.observer = observer
@@ -104,20 +112,20 @@ def resolution(self, value):
104112

105113
@property
106114
def observer(self):
107-
return self._observer.transform_to(HeliographicStonyhurst)
115+
return self._observer
108116

109117
@observer.setter
110118
def observer(self, value):
111-
self._observer = value
119+
self._observer = value.transform_to(HeliographicStonyhurst)
112120

113121
@property
114-
def pad_fov(self) -> u.arcsec:
122+
def pad_fov(self) -> u.pixel:
115123
return self._pad_fov
116124

117125
@pad_fov.setter
118126
def pad_fov(self, value):
119127
if value is None:
120-
value = [0, 0] * u.arcsec
128+
value = [0, 0] * u.pixel
121129
self._pad_fov = value
122130

123131
@property
@@ -144,21 +152,17 @@ def get_instrument_name(self, channel):
144152
return self.name
145153

146154
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.')
155+
raise NotImplementedError
152156

153157
@property
154158
def projected_frame(self):
155-
return Helioprojective(observer=self.observer, obstime=self.observer.obstime)
159+
return Helioprojective(observer=self.observer)
156160

157-
@property
161+
@cached_property
158162
@u.quantity_input
159163
def pixel_area(self) -> u.cm**2:
160164
"""
161-
Pixel area
165+
Cartesian area on the surface of the Sun covered by a single pixel.
162166
"""
163167
sa_equiv = solar_angle_equivalency(self.observer)
164168
res = (1*u.pix * self.resolution).to('cm', equivalencies=sa_equiv)
@@ -184,18 +188,18 @@ def observe(self, skeleton, save_directory=None, channels=None, **kwargs):
184188
skeleton : `~synthesizAR.Skeleton`
185189
save_directory : `str` or path-like
186190
"""
187-
check_visible = kwargs.pop('check_visible', False)
188191
if channels is None:
189192
channels = self.channels
190193
try:
191194
import distributed
192195
client = distributed.get_client()
193196
except (ImportError, ValueError):
194197
client = None
195-
coordinates = skeleton.all_coordinates
198+
ref_coord, n_pixels = self.get_fov(skeleton.all_coordinates)
199+
wcs = astropy.wcs.WCS(header=self.get_header(ref_coord, n_pixels))
196200
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)
201+
pixel_locations = wcs.world_to_pixel(coordinates_centers)
202+
visibilities = coordinates_centers.transform_to(self.projected_frame).is_visible()
199203
maps = {}
200204
for channel in channels:
201205
# Compute intensity as a function of time and field-aligned coordinate
@@ -244,20 +248,19 @@ def observe(self, skeleton, save_directory=None, channels=None, **kwargs):
244248
kernels_interp = client.gather(kernel_interp_futures)
245249
kernels = np.concatenate([u.Quantity(*k) for k in kernels_interp], axis=1)
246250

247-
header = self.get_header(channel, coordinates)
251+
header = self.get_header(ref_coord, n_pixels, channel=channel)
248252
# Build a map for each timestep
249253
maps[channel.name] = []
250254
for i, time in enumerate(self.observing_time):
251255
m = self.integrate_los(
252256
time,
253257
channel,
254258
skeleton,
255-
coordinates_centers_projected,
256-
bins,
257-
bin_range,
259+
pixel_locations,
260+
n_pixels,
261+
visibilities,
258262
header,
259-
kernels=kernels[i],
260-
check_visible=check_visible)
263+
kernels=kernels[i])
261264
m = self.convolve_with_psf(m, channel)
262265
if save_directory is None:
263266
maps[channel.name].append(m)
@@ -363,8 +366,15 @@ def interpolate_to_instrument_time(kernel, loop, observing_time, axis=0):
363366
kernel_interp = u.Quantity(f_t(observing_time.value), kernel_unit)
364367
return kernel_interp
365368

366-
def integrate_los(self, time, channel, skeleton, coordinates_centers, bins, bin_range, header,
367-
kernels=None, check_visible=False):
369+
def integrate_los(self,
370+
time,
371+
channel,
372+
skeleton,
373+
pixel_locations,
374+
n_pixels,
375+
visibilities,
376+
header,
377+
kernels=None):
368378
# Compute weights
369379
if kernels is None:
370380
i_time = np.where(time == self.observing_time)[0][0]
@@ -376,27 +386,24 @@ def integrate_los(self, time, channel, skeleton, coordinates_centers, bins, bin_
376386
# average along the LOS
377387
if not self.average_over_los:
378388
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)
383389
# Bin
384-
blc, trc = bin_range
390+
# NOTE: Bin order is (y,x) or (row, column)
391+
bins = n_pixels.to_value('pixel').astype(int)
392+
bin_edges = (np.linspace(-0.5, bins[1]-0.5, bins[1]+1),
393+
np.linspace(-0.5, bins[0]-0.5, bins[0]+1))
385394
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,
395+
pixel_locations[1],
396+
pixel_locations[0],
397+
bins=bin_edges,
398+
weights=kernels.to_value(self._expected_unit) * visibilities,
391399
)
392400
# For some quantities, need to average over all components along a given LOS
393401
if self.average_over_los:
394402
_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,
403+
pixel_locations[1],
404+
pixel_locations[0],
405+
bins=bin_edges,
406+
weights=visibilities,
400407
)
401408
hist /= np.where(_hist == 0, 1, _hist)
402409
# NOTE: Purposefully using a nonstandard key to record this time as we do not
@@ -407,60 +414,61 @@ def integrate_los(self, time, channel, skeleton, coordinates_centers, bins, bin_
407414
new_header = copy.deepcopy(header)
408415
new_header['date_sim'] = (self.observer.obstime + time).isot
409416

410-
return Map(hist.T, new_header)
417+
return Map(hist, new_header)
411418

412-
def get_header(self, channel, coordinates):
419+
def get_header(self, ref_coord, n_pixels: u.pixel, channel=None):
413420
"""
414-
Create the FITS header for a given channel and set of loop coordinates
415-
that define the needed FOV.
421+
Create the FITS header for a given channel.
422+
423+
Parameters
424+
----------
425+
ref_coord: `~astropy.coordinates.SkyCoord`
426+
Reference coordinate coincident with the center of the field
427+
of view
428+
n_pixels: `~astropy.units.Quantity`
429+
Pixel extent in the x (horizontal) and y (vertical) direction
430+
channel: `ChannelBase`, optional
416431
"""
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.
432+
# NOTE: channel is a kwarg so that a WCS can be computed without specifying
433+
# a channel as these keywords do not affect the WCS
434+
if channel is None:
435+
instrument = None
436+
wavelength = None
437+
else:
438+
instrument = self.get_instrument_name(channel)
439+
wavelength = channel.channel
423440
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)
441+
tuple(n_pixels[::-1].to_value('pixel')), # swap order because it expects (row,column)
442+
ref_coord,
443+
reference_pixel=(n_pixels - 1*u.pix) / 2, # center of lower left pixel is (0,0)
427444
scale=self.resolution,
428445
observatory=self.observatory,
429-
instrument=self.get_instrument_name(channel),
446+
instrument=instrument,
430447
telescope=self.telescope,
431448
detector=self.detector,
432-
wavelength=channel.channel,
449+
wavelength=wavelength,
433450
unit=self._expected_unit,
434451
)
435452
return header
436453

437-
def get_detector_array(self, coordinates):
454+
def get_fov(self, coordinates):
438455
"""
439-
Calculate the number of pixels in the detector FOV and the physical coordinates of the
440-
bottom left and top right corners.
456+
Return the coordinate at the center of the FOV and the width in pixels.
441457
"""
442458
if self.fov_center is not None and self.fov_width is not None:
443459
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-
)
460+
n_pixels = (self.fov_width / self.resolution).decompose().to('pixel')
456461
else:
457462
# If not specified, derive FOV from loop coordinates
458463
coordinates = coordinates.transform_to(self.projected_frame)
459-
bottom_left_corner, top_right_corner = find_minimum_fov(
460-
coordinates, padding=self.pad_fov,
461-
)
464+
bottom_left_corner, top_right_corner = find_minimum_fov(coordinates)
462465
delta_x = top_right_corner.Tx - bottom_left_corner.Tx
463466
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)
467+
center = SkyCoord(Tx=bottom_left_corner.Tx+delta_x/2,
468+
Ty=bottom_left_corner.Ty+delta_y/2,
469+
frame=bottom_left_corner.frame)
470+
pixels_x = int(np.ceil((delta_x / self.resolution[0]).decompose()).value)
471+
pixels_y = int(np.ceil((delta_y / self.resolution[1]).decompose()).value)
472+
n_pixels = u.Quantity([pixels_x, pixels_y], 'pixel')
473+
n_pixels += self.pad_fov
474+
return center, n_pixels

0 commit comments

Comments
 (0)