import re
from functools import partial
import astropy
import bokeh
import colorcet
import healpy as hp
import numpy as np
import pandas as pd
import rubin_scheduler.scheduler.features
import rubin_scheduler.scheduler.surveys # noqa: F401
from astropy.time import Time
from uranography.api import HorizonMap, Planisphere, make_zscale_linear_cmap
from schedview.compute.camera import LsstCameraFootprintPerimeter
[docs]
def map_survey_healpix(
mjd,
hpix_data,
map_key,
nside,
map_class=HorizonMap,
map_kwargs=None,
cmap=None,
cmap_scale="full",
conditions=None,
survey=None,
):
"""Map a healpix map of a survey at a given MJD.
Parameters
----------
mjd : `float`
The MJD at which to map the survey.
hpix_data : `dict`
A dictionary of healpix maps. The map with `map_key` will be mapped
to pixel color, and others will be shown in the hover tool.
map_key : `str`
The key in hpix_data corresponding to the healpix map to display.
nside : `int`
The nside at which to show the healpix maps.
map_class : `class`, optional
The class of map to use. Defaults to uranography.HorizonMap.
map_kwargs : `dict`, optional
Keyword arguments to pass to map_class.
cmap : `bokeh.models.mappers.ColorMapper`, optional
A color mapper to use for the map. Defaults to a linear cmap with the
"Inferno256" palette.
cmap_scale : `str`, optional
The scale to use for the cmap. Defaults to "full", which uses the full
range of values in the healpix map. Alternatively, "zscale" can be
used to use a zscale cmap.
conditions : `rubin_scheduler.scheduler.features.Conditions`, optional
Default is None.
The observing conditions at which to map the survey, used to determine
telescope pointing. If None, do not mark telescope pointing.
survey : `rubin_scheduler.scheduler.surveys.BaseSurvey`, optional
Default is None.
The survey with fields to mark on the map.
If None or an unsuitable survey type, do not mark survey fields.
Returns
-------
sky_map : `uranography.SphereMap`
The map of the sky.
"""
if map_kwargs is None:
map_kwargs = {}
sun_coords = astropy.coordinates.get_body("sun", Time(mjd, format="mjd"))
moon_coords = astropy.coordinates.get_body("moon", Time(mjd, format="mjd"))
sky_map = map_class(mjd=mjd, **map_kwargs)
sky_map.sliders["mjd"].visible = False
if cmap is None:
min_good_value = np.nanmin(hpix_data[map_key])
max_good_value = np.nanmax(hpix_data[map_key])
if min_good_value == max_good_value:
cmap = bokeh.transform.linear_cmap(
"value",
"Greys256",
min_good_value - 1,
max_good_value + 1,
)
elif cmap_scale == "full":
cmap = bokeh.transform.linear_cmap(
"value",
"Inferno256",
np.nanmin(hpix_data[map_key]),
np.nanmax(hpix_data[map_key]),
)
elif cmap_scale == "zscale":
make_zscale_linear_cmap(hpix_data[map_key])
else:
raise ValueError(f"Unrecognized cmap_scale: {cmap_scale}")
sky_map.add_healpix(
hpix_data[map_key],
nside=nside,
cmap=cmap,
ds_name="hpix_ds",
name="hpix_renderer",
)
# Add the hovertool
hpix_datasource = sky_map.plot.select(name="hpix_ds")[0]
hpix_renderer = sky_map.plot.select(name="hpix_renderer")[0]
shown_hpix_data = dict(hpix_datasource.data)
shown_hpids = shown_hpix_data["hpid"]
tooltips = [
(f"hpid (nside={nside})", "@hpid"),
("R. A. (deg)", "@center_ra"),
("Declination (deg)", "@center_decl"),
]
sky_map.add_horizon_graticules()
# If we have alt/az coordinates, include them
if "x_hz" in shown_hpix_data.keys() and "y_hz" in shown_hpix_data.keys():
x_hz = np.mean(np.array(shown_hpix_data["x_hz"]), axis=1)
y_hz = np.mean(np.array(shown_hpix_data["y_hz"]), axis=1)
zd = np.degrees(np.sqrt(x_hz**2 + y_hz**2))
alt = 90 - zd
az = np.degrees(np.arctan2(-x_hz, y_hz))
# Calculate airmass using Kirstensen (1998), good to the horizon.
# Models the atmosphere with a uniform spherical shell with a height
# 1/470 of the radius of the earth.
# https://doi.org/10.1002/asna.2123190313
a_cos_zd = 470 * np.cos(np.radians(zd))
airmass = np.sqrt(a_cos_zd**2 + 941) - a_cos_zd
if "zd" not in shown_hpix_data and "zd" not in hpix_data:
shown_hpix_data["zd"] = zd
tooltips.append(("Zenith Distance (deg)", "@zd"))
if "alt" not in shown_hpix_data and "alt" not in hpix_data:
shown_hpix_data["alt"] = alt
tooltips.append(("Altitude (deg)", "@alt"))
if "azimuth" not in shown_hpix_data and "azimuth" not in hpix_data:
shown_hpix_data["azimuth"] = az
tooltips.append(("Azimuth (deg)", "@azimuth"))
if "airmass" not in shown_hpix_data and "airmass" not in hpix_data:
shown_hpix_data["airmass"] = airmass
tooltips.append(("airmass", "@airmass"))
sky_map.add_horizon(
89.99, line_kwargs={"color": "#000000", "line_width": 2, "legend_label": "Horizon"}
)
sky_map.add_horizon(
70, line_kwargs={"color": "#D55E00", "line_width": 2, "legend_label": "ZD = 70 degrees"}
)
# "line_dash": "dashed"
sky_map.add_ecliptic(legend_label="Ecliptic", line_width=2, color="#009E73", line_dash="dashed")
sky_map.add_galactic_plane(
legend_label="Galactic plane", color="#0072B2", line_width=2, line_dash="dotted"
)
sky_map.add_marker(
sun_coords.ra.deg,
sun_coords.dec.deg,
name="Sun",
glyph_size=15,
circle_kwargs={
"color": "brown",
"legend_label": "Sun position",
},
)
sky_map.add_marker(
moon_coords.ra.deg,
moon_coords.dec.deg,
name="Moon",
glyph_size=15,
circle_kwargs={
"color": "orange",
"legend_label": "Moon position",
"tags": ["circle", "orange"],
},
)
if conditions is not None and conditions.tel_az is not None and conditions.tel_alt is not None:
telescope_marker_data_source = bokeh.models.ColumnDataSource(
data={
"az": [np.degrees(conditions.tel_az)],
"alt": [np.degrees(conditions.tel_alt)],
"name": ["telescope_pointing"],
"glyph_size": [20],
},
name="telescope_pointing",
)
sky_map.add_marker(
data_source=telescope_marker_data_source,
name="telescope_pointing_marker",
circle_kwargs={"color": "green", "fill_alpha": 0.5, "legend_label": "Telescope pointing"},
)
if survey is not None:
try:
try:
ra_deg = list(survey.ra_deg)
dec_deg = list(survey.dec_deg)
except TypeError:
ra_deg = [survey.ra_deg]
dec_deg = [survey.dec_deg]
survey_field_data_source = bokeh.models.ColumnDataSource(
data={
"ra": ra_deg,
"decl": dec_deg,
"name": ["survey_pointing {i}" for i, ra in enumerate(ra_deg)],
"glyph_size": [20] * len(ra_deg),
},
name="survey_pointings",
)
sky_map.add_marker(
data_source=survey_field_data_source,
name="survey_field_marker",
circle_kwargs={"color": "black", "fill_alpha": 0, "legend_label": "Survey field(s)"},
)
except AttributeError:
pass
for key in hpix_data:
column_name = key.replace(" ", "_").replace(".", "_").replace("@", "_")
shown_hpix_data[column_name] = hpix_data[key][shown_hpids]
label = re.sub(" @[0-9]*$", "", key)
tooltips.append((label, f"@{column_name}"))
hpix_datasource.data = shown_hpix_data
hover_tool = bokeh.models.HoverTool(renderers=[hpix_renderer], tooltips=tooltips)
sky_map.plot.tools.append(hover_tool)
return sky_map
[docs]
def map_visits_over_hpix(
visits,
conditions,
map_hpix,
plot=None,
scale_limits=None,
palette=colorcet.blues,
map_class=Planisphere,
prerender_hpix=True,
):
"""Plot visit locations over a healpix map.
Parameters
----------
visits : `pd.DataFrame`
The table of visits to plot, with columns matching the opsim database
definitions.
conditions : `rubin_scheduler.scheduler.features.Conditions`
An instance of a rubin_scheduler conditions object.
map_hpix : `numpy.array`
An array of healpix values
plot : `bokeh.models.plots.Plot` or `None`
The bokeh plot on which to make the plot. None creates a new plot.
None by default.
scale_limits : `list` of `float` or `None`
The scale limits for the healpix values. If None, use zscale to set
the scale.
palette : `str`
The bokeh palette to use for the healpix map.
map_class : `class`, optional
The class of map to use. Defaults to uranography.Planisphere.
prerender_hpix : `bool`, optional
Pre-render the healpix map? Defaults to True
Returns
-------
plot : `bokeh.models.plots.Plot`
The plot with the map
"""
camera_perimeter = LsstCameraFootprintPerimeter()
if plot is None:
plot = bokeh.plotting.figure(frame_width=256, frame_height=256, match_aspect=True)
sphere_map = map_class(mjd=conditions.mjd, plot=plot)
if scale_limits is None:
try:
good_values = map_hpix[~map_hpix.mask]
except AttributeError:
good_values = map_hpix
cmap = make_zscale_linear_cmap(good_values, palette=palette)
else:
cmap = bokeh.transform.linear_cmap("value", palette, scale_limits[0], scale_limits[1])
if prerender_hpix:
# Convert the healpix map into an image raster, and send that instead
# the full healpix map (sent as one polygon for each healpixel).
# An high nside, this should reduce the data sent to the browser.
# However, it will not be responsive to controls.
if not map_class == Planisphere:
raise NotImplementedError()
if not plot.frame_width == plot.frame_height:
raise NotImplementedError()
xsize = plot.frame_width
ysize = plot.frame_height
# For Lambert Azimuthal Equal Area, projection space is 4 radians wide
# and high, so projection units per pixel is 4 radians/xsize.
# reso is in units of arcmin, though.
reso = 60 * np.degrees(4.0 / xsize)
projector = hp.projector.AzimuthalProj(
rot=sphere_map.laea_rot, xsize=xsize, ysize=ysize, reso=reso, lamb=True
)
map_raster = projector.projmap(map_hpix, partial(hp.vec2pix, hp.npix2nside(len(map_hpix))))
# Set area outside projection to nan, not -inf, so bokeh does not
# try coloring it.
map_raster[np.isneginf(map_raster)] = np.nan
reso_radians = np.radians(projector.arrayinfo["reso"] / 60)
width_hpxy = reso_radians * map_raster.shape[0]
height_hpxy = reso_radians * map_raster.shape[1]
sphere_map.plot.image(
[map_raster],
x=-width_hpxy / 2,
y=-height_hpxy / 2,
dw=width_hpxy,
dh=height_hpxy,
color_mapper=cmap.transform,
level="image",
)
else:
sphere_map.add_healpix(map_hpix, nside=hp.npix2nside(len(map_hpix)), cmap=cmap)
if len(visits) > 0:
ras, decls = camera_perimeter(visits.fieldRA, visits.fieldDec, visits.rotSkyPos)
perimeter_df = pd.DataFrame(
{
"ra": ras,
"decl": decls,
}
)
sphere_map.add_patches(
perimeter_df, patches_kwargs={"fill_color": None, "line_color": "black", "line_width": 1}
)
sphere_map.decorate()
sphere_map.add_marker(
ra=np.degrees(conditions.sun_ra),
decl=np.degrees(conditions.sun_dec),
name="Sun",
glyph_size=8,
circle_kwargs={"color": "yellow", "fill_alpha": 1},
)
sphere_map.add_marker(
ra=np.degrees(conditions.moon_ra),
decl=np.degrees(conditions.moon_dec),
name="Moon",
glyph_size=8,
circle_kwargs={"color": "orange", "fill_alpha": 0.8},
)
return plot
[docs]
def create_hpix_visit_map_grid(hpix_maps, visits, conditions, **kwargs):
"""Create a grid of healpix maps with visits overplotted.
Notes
-----
Additional keyword args are passed to map_visits_over_hpix.
Parameters
----------
map_hpix : `numpy.array`
An array of healpix values
visits : `pd.DataFrame`
The table of visits to plot, with columns matching the opsim database
definitions.
conditions : `rubin_scheduler.scheduler.features.Conditions`
An instance of a rubin_scheduler conditions object.
Returns
-------
plot : `bokeh.models.plots.Plot`
The plot with the map
"""
visit_map = {}
for band in hpix_maps:
visit_map[band] = map_visits_over_hpix(
visits.query(f"filter == '{band}'"), conditions, hpix_maps[band], **kwargs
)
visit_map[band].title = band
# Convert the dictionary of maps into a list of lists,
# corresponding to the rows of the grid.
num_cols = 3
map_lists = []
for band_idx, band in enumerate(hpix_maps):
if band_idx % num_cols == 0:
map_lists.append([visit_map[band]])
else:
map_lists[-1].append(visit_map[band])
map_grid = bokeh.layouts.gridplot(map_lists)
return map_grid