Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
171 changes: 161 additions & 10 deletions schedview/plot/visit_skymaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
split_healpix_by_resolution,
)

from schedview import DayObs
from schedview.collect import load_bright_stars
from schedview.compute.camera import LsstCameraFootprintPerimeter
from schedview.compute.footprint import find_healpix_area_polygons
Expand Down Expand Up @@ -355,19 +356,22 @@ def _add_mjd_slider(self, *args, **kwargs) -> Self:
self: `VisitMapBuilder`
Returns self to support method chaining.
"""
mjd_now = Time.now().mjd

# Appease type checker
assert isinstance(mjd_now, SupportsFloat)
if "start" not in kwargs:
kwargs["start"] = DayObs.from_time(
self.mjd if self.visits is None else self.visits[self.mjd_column].min()
).sunset.mjd

slider_kwargs = {
"start": self.visits[self.mjd_column].min() if self.visits is not None else float(mjd_now) - 1,
"end": self.visits[self.mjd_column].max() if self.visits is not None else float(mjd_now) + 1,
}
slider_kwargs.update(kwargs)
if "end" not in kwargs:
kwargs["end"] = DayObs.from_time(
self.mjd if self.visits is None else self.visits[self.mjd_column].max()
).sunrise.mjd

if "mjd" not in self.ref_map.sliders:
self.ref_map.add_mjd_slider(*args, **slider_kwargs)
if "mjd" in self.ref_map.sliders:
self.ref_map.sliders["mjd"].start = kwargs["start"]
self.ref_map.sliders["mjd"].end = kwargs["end"]
else:
self.ref_map.add_mjd_slider(*args, **kwargs)

self.mjd_slider = self.ref_map.sliders["mjd"]

Expand Down Expand Up @@ -1101,6 +1105,153 @@ def hide_up_selector(self) -> Self:

return self

def add_alt_visit_patches(self, visits: pd.DataFrame, **kwargs: Any) -> Self:
"""Add visit patches for alternate sets of visits to the map.
Similar to completed visit patches, but visits are outlined instead
of filled.

Parameters
----------
visits : `pandas.DataFrame` or `None`
Table of visits, with the same columns required by the
add_visit_patches method, plus ``sim_index``.
**kwargs
Additional keyword arguments passed to the underlying
`bokeh.plotting.figure.patches` call.

Returns
-------
self: `VisitMapBuilder`
Returns self to support method chaining.
"""
self.alt_visits_ds = {}
self.alt_visits = visits
alt_visit_columns = ["sim_index"] + self.visit_columns
present_visit_columns = [c for c in alt_visit_columns if c in self.alt_visits.columns]
for band in "ugrizy":
in_band_mask = self.alt_visits[self.band_column] == band
band_visits = self.alt_visits.loc[in_band_mask, present_visit_columns].copy()

if len(band_visits) < 1:
continue

assert pd.api.types.is_float_dtype(band_visits[self.ra_column])
assert pd.api.types.is_float_dtype(band_visits[self.decl_column])
assert pd.api.types.is_float_dtype(band_visits[self.rot_column])
# np.asarray makes type checking happier than .values
visits_ra = np.asarray(band_visits[self.ra_column])
visits_decl = np.asarray(band_visits[self.decl_column])
visits_rot = np.asarray(band_visits[self.rot_column])

ras, decls = self.camera_perimeter(visits_ra, visits_decl, visits_rot)
band_visits = band_visits.assign(
ra=ras,
decl=decls,
mjd=band_visits[self.mjd_column].values,
)

patches_kwargs = {
"line_color": self.visit_fill_colors[band],
"fill_alpha": 0.0,
"name": "alt_visit_patches",
"line_alpha": 1.0,
}
patches_kwargs.update(kwargs)

self.alt_visits_ds[band] = self.ref_map.add_patches(
band_visits,
patches_kwargs=patches_kwargs,
)

for spheremap in self.spheremaps[1:]:
spheremap.add_patches(data_source=self.alt_visits_ds[band], patches_kwargs=patches_kwargs)

return self

def add_alt_visits_selector(
self, show_alpha: float = 1.0, future_alpha: float = 0.1, other_sim_alpha: float = 0.0
) -> Self:
"""Hide visits from alternate sets of visits based on which alternate
set is selected, or whether the visits start after the mjd slider.

Parameters
----------
show_alpha: `float`
The alpha of shown visits.
future_alpha: `float`
The alpha of visits in the future.
other_sim_alpha: `float`
The alpha of visits from unselected sims.

Returns
-------
self : `VisitMapBuilder`
Returns ``self`` to enable method chaining.
"""

# Derive the list of sim indexes and their labels from
# the sim_visits.
visit_set_labels = (
self.alt_visits.loc[:, ["sim_index", "label"]].groupby("sim_index").first().to_dict()["label"]
)
# This specific type hint is needed to get type checkers to accept
# it as an options value in the Select instantiation below.
alt_options: list[str | tuple[Any, str]] = [
(str(i), str(label)) for i, label in visit_set_labels.items()
]
default_value = alt_options[0][0]
alt_visits_selector = bokeh.models.Select(
value=default_value, options=alt_options, name="alt_visits_selector"
)
self.ref_map.controls["alt_visits_selector"] = alt_visits_selector

transform_args = {
"show_value": show_alpha,
"future_value": future_alpha,
"other_visits_value": other_sim_alpha,
"alt_visits_selector": self.ref_map.controls["alt_visits_selector"],
}

if "mjd" not in self.ref_map.sliders:
# The slider must exist for this feature to work, but if we
# have not yet explicitly added it, make it invisible.
self.ref_map.add_mjd_slider(visible=False)

transform_args["mjd_slider"] = self.ref_map.sliders["mjd"]
hide_js = """
const result = new Array(xs.length)
const selected_sim = parseInt(alt_visits_selector.value)
for (let i = 0; i < xs.length; i++) {
if (selected_sim === sim_index[i]) {
if (mjd_slider.value >= xs[i]) {
result[i] = show_value
} else {
result[i] = future_value
}
} else {
result[i] = other_visits_value
}
}
return result
"""

for spheremap in self.spheremaps:
visit_renderers = spheremap.plot.select(name="alt_visit_patches")
if visit_renderers:
for renderer in visit_renderers:
transform_args["sim_index"] = renderer.data_source.data["sim_index"]
transform = bokeh.models.CustomJSTransform(args=transform_args, v_func=hide_js)
renderer.glyph.line_alpha = bokeh.transform.transform("mjd", transform)

# When a new selection is made, make sure the transform
# is rerun.
alt_visits_selector_callback = bokeh.models.CustomJS(
args={"data_source": renderer.data_source}, code="data_source.change.emit()"
)
alt_visits_selector.js_on_change("value", alt_visits_selector_callback)

return self

def _connect_controls(self):
# Must be called after all data sources and contros have been added.

Expand Down
93 changes: 80 additions & 13 deletions tests/test_plot_visit_skymaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,42 @@

TEST_VISITS = pd.DataFrame(
{
"fieldRA": [10.0, 20.0],
"fieldDec": [-5.0, 15.0],
"observationStartMJD": [59000.0, 59001.0],
"band": ["g", "r"],
"rotSkyPos": [0.0, 45.0],
"observationId": [1, 2],
"start_timestamp": pd.to_datetime(["2020-01-01", "2020-01-02"]),
"observationStartLST": [150.0, 160.0],
"paraAngle": [0.0, 0.0],
"azimuth": [180.0, 190.0],
"altitude": [45.0, 50.0],
"observation_reason": ["science", "science"],
"science_program": ["prog1", "prog2"],
"fieldRA": [59.780536346361515, 62.84973607079297],
"fieldDec": [-49.19329541107385, -47.84742038715563],
"observationStartMJD": [61105.09302481122, 61105.09346446205],
"band": ["i", "i"],
"rotSkyPos": [46.55499643418069, 41.58312568172861],
"observationId": [2026030500054, 2026030500055],
"start_timestamp": pd.to_datetime(["2026-03-06T02:13:57Z", "2026-03-06T02:14:35Z"]),
"observationStartLST": [126.57549341396043, 126.73420103825885],
"paraAngle": [95.01157303654843, 94.0006966560346],
"azimuth": [228.99332822379625, 230.8847273192659],
"altitude": [37.30089164250424, 39.122240613722006],
"observation_reason": ["ddf_edfs_a", "ddf_edfs_b"],
"science_program": ["BLOCK-407", "BLOCK-407"],
}
)

TEST_ALT_VISITS = pd.DataFrame(
{
"fieldRA": [29.155717698495256, 28.196457586307357],
"fieldDec": [-21.68100073471875, -18.68709611868367],
"observationStartMJD": [61105.0041463103, 61105.00439657373],
"band": ["z", "z"],
"rotSkyPos": [78.04543083088815, 79.83485071859705],
"observationId": [0, 1],
"start_timestamp": pd.to_datetime(["2026-03-06T00:05:58Z", "2026-03-06T00:06:19Z"]),
"observationStartLST": [94.4899896086903, 94.58033111527438],
"paraAngle": [113.09872516602272, 114.84576306113031],
"azimuth": [261.99025952876906, 264.70873478710547],
"altitude": [31.76771615131967, 29.65491863783054],
"observation_reason": ["twilight_near_sun", "twilight_near_sun"],
"science_program": ["BLOCK-421", "BLOCK-421"],
"sim_index": [1, 1],
"label": ["Sim 1 Label", "Sim 1 Label"],
}
)

FOOTPRINT_NSIDE = 16


Expand Down Expand Up @@ -235,3 +256,49 @@ def test_add_footprint_outlines():
assert len(outline_renderers) > 0

_save_and_check_viewable_html(viewable)


def test_add_alt_visit_patches():
"""Test that add_alt_visit_patches adds alternate visit patches."""

builder = VisitMapBuilder(TEST_VISITS)
builder.add_alt_visit_patches(TEST_ALT_VISITS)
viewable = builder.build()

# Check that alt_visit_patches were added
alt_visit_patches = list(viewable.select({"name": "alt_visit_patches"}))
assert len(alt_visit_patches) > 0

# Verify that the patches have the correct class and styling
for renderer in alt_visit_patches:
assert isinstance(renderer, bokeh.models.renderers.glyph_renderer.GlyphRenderer)
assert renderer.glyph.fill_alpha == 0.0
assert renderer.glyph.line_alpha == 1.0
assert renderer.glyph.line_color is not None

_save_and_check_viewable_html(viewable)


def test_add_alt_visits_selector():
"""Test that add_alt_visits_selector adds a Bokeh Select widget
with correct options."""

builder = VisitMapBuilder(TEST_VISITS)
builder.add_alt_visit_patches(TEST_ALT_VISITS)
builder.add_alt_visits_selector()
viewable = builder.build()

# Check that the selector was added
selector_renderers = list(viewable.select({"name": "alt_visits_selector"}))
assert len(selector_renderers) > 0

# Verify it's a bokeh Select widget
selector = selector_renderers[0]
assert isinstance(selector, bokeh.models.Select)

# Verify the options are correctly set
# The selector should have options based on sim_index values in the data
expected_options = [("1", "Sim 1 Label")] # Based on the sim_index=1 and label provided
assert selector.options == expected_options

_save_and_check_viewable_html(viewable)
Loading