Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handling larger range for BUNIT conversions #700

Merged
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cf2e3f7
Add beam equiv for current and target unit
e-koch Feb 4, 2021
e1c1108
Add Jy/beam -> Jy/sr -> bright. temp. cube conversions
e-koch Feb 4, 2021
67badb8
Add conversions to Jy/pix
e-koch Feb 4, 2021
972b9c1
Start of test for full range of cube unit converions
e-koch Feb 4, 2021
70767a6
Fix extra arg causing failures
e-koch Feb 4, 2021
a12bd02
Test round trip for comparison between all BUNIT sets
e-koch Feb 5, 2021
83e7e30
Add round trip manual equivalency checks
e-koch Feb 5, 2021
72f7fd7
Fix the Jy/pix converion definitions
e-koch Mar 11, 2021
37fc601
Add full units testing including round trips
e-koch Mar 11, 2021
b922bfb
Abstract out the equivalency bunit checks to a separate function
e-koch Mar 12, 2021
f0f12c1
Also include a mJy/mK unit test
e-koch Mar 12, 2021
1c5c68a
Include check for already equivalent units
e-koch Mar 12, 2021
f5a98ad
Add docstring to bunit convertsions
e-koch Mar 12, 2021
751d5eb
Initial LDO conversions for bunits and 2D projections
e-koch Mar 12, 2021
5cd5771
Return factor instead of equivalencies
e-koch Mar 16, 2021
9a158e9
Bunit conversions working for all LDOs w/o multiple beams
e-koch Mar 16, 2021
8115cbd
Add 2D and 1D round-trip bunit conversion tests
e-koch Mar 16, 2021
0019125
Rework conversion function for varying res objects
e-koch Apr 29, 2021
3c1cafb
Fixes for equivalencies with changing beam size
e-koch Apr 29, 2021
2d2b03b
Remove manually providing beam equivs in test. This should now be han…
e-koch Apr 29, 2021
88eadb6
Add round trip unit conversion test for varying res cubes
e-koch Apr 29, 2021
1260d4c
Add pixels_per_beam for multi beam objects
e-koch Apr 29, 2021
f934a90
Add a multibeam Jy/pix roundtrip test
e-koch Apr 29, 2021
0fa8e0f
Handle passing frequencies for 1D object unit conversions
e-koch Apr 29, 2021
aa19665
Add a multibeam 1D spec unit conversion roundtrip test
e-koch Apr 29, 2021
e4afcd9
Cleanup old code
e-koch Apr 29, 2021
329b2c6
Return an array of factors
e-koch May 6, 2021
d6d8d58
Break down the steps in the bunit conversion and add to the docstring
e-koch May 6, 2021
30de78e
Apply suggestions from code review
e-koch May 6, 2021
b0936d8
Boost the radio-beam version to v0.3.3; update the note in the bunit …
e-koch May 13, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions spectral_cube/base_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,15 @@ def beams(self, obj):

self._beams = obj

@property
@cached
def pixels_per_beam(self):
pixels_per_beam = [(beam.sr /
(astropy.wcs.utils.proj_plane_pixel_area(self.wcs) *
u.deg**2)).to(u.one).value for beam in self.beams]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WHOA, I didn't know u.one is u.dimensionless! neat!


return pixels_per_beam

@property
def unmasked_beams(self):
return self._beams
Expand Down
185 changes: 185 additions & 0 deletions spectral_cube/cube_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import contextlib
import warnings
from copy import deepcopy

try:
import builtins
except ImportError:
Expand All @@ -10,6 +12,7 @@

import dask.array as da
import numpy as np
from astropy.wcs.utils import proj_plane_pixel_area
from astropy.wcs import (WCSSUB_SPECTRAL, WCSSUB_LONGITUDE, WCSSUB_LATITUDE)
from . import wcs_utils
from .utils import FITSWarning, AstropyUserWarning, WCSCelestialError
Expand Down Expand Up @@ -535,3 +538,185 @@ def world_take_along_axis(cube, position_plane, axis):
out = out.squeeze()

return out


def bunit_converters(obj, unit, equivalencies=(), freq=None):
'''
Handler for all brightness unit conversions, including: K, Jy/beam, Jy/pix, Jy/sr.
This also includes varying resolution spectral cubes, where the beam size varies along
the frequency axis.

Parameters
----------
obj : {SpectralCube, LowerDimensionalObject}
A spectral cube or any other lower dimensional object.
unit : `~astropy.units.Unit`
Unit to convert `obj` to.
equivalencies : tuple, optional
Initial list of equivalencies.
freq : `~astropy.unit.Quantity`, optional
Frequency to use for spectral conversions. If the spectral axis is available, the
frequencies will already be defined.

Outputs
-------
factor : `~numpy.ndarray`
Array of factors for the unit conversion.

'''

# Add a simple check it the new unit is already equivalent, and so we don't need
# any additional unit equivalencies
if obj.unit.is_equivalent(unit):
# return equivalencies
factor = obj.unit.to(unit, equivalencies=equivalencies)
return np.array([factor])

# Determine the bunit "type". This will determine what information we need for the unit conversion.
has_btemp = obj.unit.is_equivalent(u.K) or unit.is_equivalent(u.K)
has_perbeam = obj.unit.is_equivalent(u.Jy/u.beam) or unit.is_equivalent(u.Jy/u.beam)
has_perangarea = obj.unit.is_equivalent(u.Jy/u.sr) or unit.is_equivalent(u.Jy/u.sr)
has_perpix = obj.unit.is_equivalent(u.Jy/u.pix) or unit.is_equivalent(u.Jy/u.pix)

# Is there any beam object defined?
has_beam = hasattr(obj, 'beam') or hasattr(obj, 'beams')

# Set if this is a varying resolution object
has_beams = hasattr(obj, 'beams')

# Define freq, if needed:
if any([has_perangarea, has_perbeam, has_btemp]):
# Create a beam equivalency for brightness temperature
# This requires knowing the frequency along the spectral axis.
if freq is None:
try:
freq = obj.with_spectral_unit(u.Hz).spectral_axis
except AttributeError:
raise TypeError("Object of type {0} has no spectral "
"information. `freq` must be provided for"
" unit conversion from Jy/beam"
.format(type(obj)))
else:
if not freq.unit.is_equivalent(u.Hz):
raise u.UnitsError("freq must be given in equivalent "
"frequency units.")

freq = freq.reshape((-1,))

else:
freq = [None]

# To handle varying resolution objects, loop through "channels"
# Default to a single iteration for a 2D spatial object or when a beam is not defined
# This allows handling all 1D, 2D, and 3D data products.
if has_beams:
iter = range(len(obj.beams))
beams = obj.beams
elif has_beam:
iter = range(0, 1)
beams = [obj.beam]
else:
iter = range(0, 1)
beams = [None]

# Append the unit conversion factors
factors = []

# Iterate through spectral channels.
for ii in iter:

beam = beams[ii]

# Use the range of frequencies when the beam does not change. Otherwise, select the
# frequency corresponding to this beam.
if has_beams:
thisfreq = freq[ii]
else:
thisfreq = freq

# Changes in beam require a new equivalency for each.
this_equivalencies = deepcopy(equivalencies)

# Equivalencies for Jy per ang area.
if has_perangarea:
bmequiv_angarea = u.brightness_temperature(thisfreq)

this_equivalencies = list(this_equivalencies) + bmequiv_angarea

# Beam area equivalencies for Jy per beam and/or Jy per ang area
if has_perbeam or has_perangarea:
if not has_beam:
raise ValueError("To convert cubes with Jy/beam units, "
"the cube needs to have a beam defined.")

# create a beam equivalency for brightness temperature
bmequiv = beam.jtok_equiv(thisfreq)

# TODO: Remove check once `beamarea_equiv` is in a radio-beam release.
if hasattr(beam, 'beamarea_equiv'):
bmarea_equiv = beam.beamarea_equiv
else:
bmarea_equiv = u.beam_angular_area(beam.sr)

this_equivalencies = list(this_equivalencies) + bmequiv + bmarea_equiv

# Equivalencies for Jy per pixel area.
if has_perpix:

if not obj.wcs.has_celestial:
raise ValueError("Spatial WCS information is required for unit conversions"
" involving spatial areas (e.g., Jy/pix, Jy/sr)")

pix_area = (proj_plane_pixel_area(obj.wcs.celestial) * u.deg**2).to(u.sr)

pix_area_equiv = [(u.Jy / u.pix, u.Jy / u.sr,
lambda x: x / pix_area.value,
lambda x: x * pix_area.value)]

this_equivalencies = list(this_equivalencies) + pix_area_equiv

# Define full from brightness temp to Jy / pix.
# Otherwise isn't working in 1 step
if has_btemp:
if not has_beam:
raise ValueError("Conversions between K and Jy/beam or Jy/pix"
"requires the cube to have a beam defined.")

jtok_factor = beam.jtok(thisfreq) / (u.Jy / u.beam)

# We're going to do this piecemeal because it's easier to conceptualize
# We specifically anchor these conversions based on the beam area. So from
# beam to pix, this is beam -> angular area -> area per pixel
# Altogether:
# K -> Jy/beam -> Jy /sr - > Jy / pix
forward_factor = 1 / (jtok_factor * (beam.sr / u.beam) / (pix_area / u.pix))
reverse_factor = jtok_factor * (beam.sr / u.beam) / (pix_area / u.pix)

pix_area_btemp_equiv = [(u.K, u.Jy / u.pix,
lambda x: x * forward_factor.value,
lambda x: x * reverse_factor.value)]

this_equivalencies = list(this_equivalencies) + pix_area_btemp_equiv

# Equivalencies between pixel and angular areas.
if has_perbeam:
if not has_beam:
raise ValueError("Conversions between Jy/beam or Jy/pix"
"requires the cube to have a beam defined.")

beam_area = beam.sr

pix_area_btemp_equiv = [(u.Jy / u.pix, u.Jy / u.beam,
lambda x: x * (beam_area / pix_area).value,
lambda x: x * (pix_area / beam_area).value)]

this_equivalencies = list(this_equivalencies) + pix_area_btemp_equiv

factor = obj.unit.to(unit, equivalencies=this_equivalencies)
factors.append(factor)

if has_beams:
return factors
else:
# Slice along first axis to return a 1D array.
return factors[0]
54 changes: 10 additions & 44 deletions spectral_cube/lower_dimensional_structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from . import spectral_axis
from .io.core import LowerDimensionalObjectWrite
from .utils import SliceWarning, BeamWarning, SmoothingWarning, FITSWarning
from .cube_utils import convert_bunit
from . import cube_utils
from . import wcs_utils
from .masks import BooleanArrayMask, MaskBase

Expand All @@ -26,7 +26,6 @@
MultiBeamMixinClass, BeamMixinClass,
HeaderMixinClass
)
from . import cube_utils

__all__ = ['LowerDimensionalObject', 'Projection', 'Slice', 'OneDSpectrum']
class LowerDimensionalObject(u.Quantity, BaseNDClass, HeaderMixinClass):
Expand Down Expand Up @@ -162,48 +161,15 @@ def to(self, unit, equivalencies=[], freq=None):
# No copying
return self

if ((self.unit.is_equivalent(u.Jy / u.beam) and
not any({u.Jy/u.beam, u.K}.issubset(set(eq)) for eq in equivalencies))):
# the 'not any' above checks that there is not already a defined
# Jy<->K equivalency. If there is, the code below is redundant
# and will cause problems.
if hasattr(self, 'with_spectral_unit'):
freq = self.with_spectral_unit(u.Hz).spectral_axis

if hasattr(self, 'beams'):
factor = (self.jtok_factors(equivalencies=equivalencies) *
(self.unit*u.beam).to(u.Jy))
else:
# replace "beam" with the actual beam
if not hasattr(self, 'beam'):
raise ValueError("To convert objects with Jy/beam units, "
"the object needs to have a beam defined.")
brightness_unit = self.unit * u.beam

# create a beam equivalency for brightness temperature
if freq is None:
try:
freq = self.with_spectral_unit(u.Hz).spectral_axis
except AttributeError:
raise TypeError("Object of type {0} has no spectral "
"information. `freq` must be provided for"
" unit conversion from Jy/beam"
.format(type(self)))
else:
if not freq.unit.is_equivalent(u.Hz):
raise u.UnitsError("freq must be given in equivalent "
"frequency units.")

bmequiv = self.beam.jtok_equiv(freq)
# backport to handle astropy < 3: the beam equivalency was only
# modified to handle jy/beam in astropy 3
if bmequiv[0] == u.Jy:
bmequiv.append([u.Jy/u.beam, u.K, bmequiv[2], bmequiv[3]])

factor = brightness_unit.to(unit,
equivalencies=bmequiv + list(equivalencies))
if freq is None and 'RESTFRQ' in self.header:
freq = self.header['RESTFRQ'] * u.Hz

else:
# scaling factor
factor = self.unit.to(unit, equivalencies=equivalencies)
# Create the tuple of unit conversions needed.
factor = cube_utils.bunit_converters(self, unit, equivalencies=equivalencies,
freq=freq)

converted_array = (self.quantity * factor).value

Expand Down Expand Up @@ -412,7 +378,7 @@ def from_hdu(hdu):
mywcs = wcs.WCS(hdu.header)

if "BUNIT" in hdu.header:
unit = convert_bunit(hdu.header["BUNIT"])
unit = cube_utils.convert_bunit(hdu.header["BUNIT"])
meta["BUNIT"] = hdu.header["BUNIT"]
else:
unit = None
Expand Down Expand Up @@ -673,7 +639,7 @@ def from_hdu(hdu):
mywcs = wcs.WCS(hdu.header)

if "BUNIT" in hdu.header:
unit = convert_bunit(hdu.header["BUNIT"])
unit = cube_utils.convert_bunit(hdu.header["BUNIT"])
meta["BUNIT"] = hdu.header["BUNIT"]
else:
unit = None
Expand Down
28 changes: 5 additions & 23 deletions spectral_cube/spectral_cube.py
Original file line number Diff line number Diff line change
Expand Up @@ -2480,20 +2480,8 @@ def to(self, unit, equivalencies=()):
# No copying
return self

if self.unit.is_equivalent(u.Jy/u.beam):
# replace "beam" with the actual beam
if not hasattr(self, 'beam') or self._beam is None:
raise ValueError("To convert cubes with Jy/beam units, "
"the cube needs to have a beam defined.")
brightness_unit = self.unit * u.beam

# create a beam equivalency for brightness temperature
bmequiv = self.beam.jtok_equiv(self.with_spectral_unit(u.Hz).spectral_axis)
factor = brightness_unit.to(unit,
equivalencies=bmequiv+list(equivalencies))
else:
# scaling factor
factor = self.unit.to(unit, equivalencies=equivalencies)
# Create the tuple of unit conversions needed.
factor = cube_utils.bunit_converters(self, unit, equivalencies=equivalencies)

# special case: array in equivalencies
# (I don't think this should have to be special cased, but I don't know
Expand Down Expand Up @@ -4062,15 +4050,9 @@ def to(self, unit, equivalencies=()):
# No copying
return self

if self.unit.is_equivalent(u.Jy/u.beam) and unit.is_equivalent(u.K):
# replace "beam" with the actual beam
if not hasattr(self, 'beams'):
raise ValueError("To convert cubes with Jy/beam units, "
"the cube needs to have beams defined.")
factor = self.jtok_factors(equivalencies=equivalencies) * (self.unit*u.beam).to(u.Jy)
else:
# scaling factor
factor = self.unit.to(unit, equivalencies=equivalencies)
# Create the tuple of unit conversions needed.
factor = cube_utils.bunit_converters(self, unit, equivalencies=equivalencies)
factor = np.array(factor)

# special case: array in equivalencies
# (I don't think this should have to be special cased, but I don't know
Expand Down
Loading