From 612f2aea37ff9bc130c79caa6277db72e003f6e3 Mon Sep 17 00:00:00 2001 From: Eric Neilsen Date: Mon, 23 Mar 2026 13:52:10 -0700 Subject: [PATCH 1/3] tools for showing alt visits sets on VisitMapBuilder --- schedview/plot/visit_skymaps.py | 143 ++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/schedview/plot/visit_skymaps.py b/schedview/plot/visit_skymaps.py index 8169d173..7a254c50 100644 --- a/schedview/plot/visit_skymaps.py +++ b/schedview/plot/visit_skymaps.py @@ -1101,6 +1101,149 @@ 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"] + ) + alt_options = [(str(i), 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. From 11a26ee81e841e6c3bdf62fada1778ab30e527f3 Mon Sep 17 00:00:00 2001 From: Eric Neilsen Date: Tue, 24 Mar 2026 08:09:02 -0700 Subject: [PATCH 2/3] Make time slider limits more reasonable --- schedview/plot/visit_skymaps.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/schedview/plot/visit_skymaps.py b/schedview/plot/visit_skymaps.py index 7a254c50..c384d74c 100644 --- a/schedview/plot/visit_skymaps.py +++ b/schedview/plot/visit_skymaps.py @@ -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 @@ -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"] From 71e891f8c87021d3a9421b23629a75ba9bec9d9b Mon Sep 17 00:00:00 2001 From: "Eric H. Neilsen, Jr." Date: Fri, 27 Mar 2026 12:51:03 -0500 Subject: [PATCH 3/3] add tests for alt visit functions in VisitMapBuilder --- schedview/plot/visit_skymaps.py | 6 ++- tests/test_plot_visit_skymaps.py | 93 +++++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/schedview/plot/visit_skymaps.py b/schedview/plot/visit_skymaps.py index c384d74c..b1aaebd5 100644 --- a/schedview/plot/visit_skymaps.py +++ b/schedview/plot/visit_skymaps.py @@ -1194,7 +1194,11 @@ def add_alt_visits_selector( visit_set_labels = ( self.alt_visits.loc[:, ["sim_index", "label"]].groupby("sim_index").first().to_dict()["label"] ) - alt_options = [(str(i), label) for i, label in visit_set_labels.items()] + # 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" diff --git a/tests/test_plot_visit_skymaps.py b/tests/test_plot_visit_skymaps.py index 6e9daafc..f2c47c30 100644 --- a/tests/test_plot_visit_skymaps.py +++ b/tests/test_plot_visit_skymaps.py @@ -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 @@ -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)