Repository: cta-observatory/ctapipe
Pull request #2549 (2024-04-25 10:19:03)

Update to bokeh 3


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1075eed3..c3ab1609 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,8 @@ on:
 
 env:
   NUMBA_NUM_THREADS: 1
+  OMP_NUM_THREADS: 1
+  MKL_NUM_THREADS: 1
   MPLBACKEND: Agg
   PYTEST_ADDOPTS: --color=yes
   GITHUB_PR_NUMBER: ${{ github.event.number }}
diff --git a/docs/changes/2544.bugfix.rst b/docs/changes/2544.bugfix.rst
deleted file mode 100644
index 8c9a08ad..00000000
--- a/docs/changes/2544.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Fix a possible out-of-bounds array access in the FlashCamExtractor.
diff --git a/docs/changes/2545.optimization.rst b/docs/changes/2545.optimization.rst
deleted file mode 100644
index 32ab7819..00000000
--- a/docs/changes/2545.optimization.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Compute deconvolution parameters in FlashCamExtractor only as needed.
diff --git a/docs/changes/2549.feature.rst b/docs/changes/2549.feature.rst
new file mode 100644
index 00000000..3e045abc
--- /dev/null
+++ b/docs/changes/2549.feature.rst
@@ -0,0 +1,1 @@
+Update bokeh dependency to version 3.x.
diff --git a/environment.yml b/environment.yml
index d632eb51..f92fcfa6 100644
--- a/environment.yml
+++ b/environment.yml
@@ -7,7 +7,7 @@ dependencies:
   - pip
   - astropy>=5.3,<7
   - black
-  - bokeh=2
+  - bokeh=3
   - nbsphinx
   - cython
   - graphviz
diff --git a/pyproject.toml b/pyproject.toml
index f414d8c6..bb57dce8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,7 +31,7 @@ requires-python = ">=3.10"
 
 dependencies = [
     "astropy >=5.3,<7.0.0a0",
-    "bokeh ~=2.0",
+    "bokeh ~=3.0",
     "docutils",
     "eventio >=1.9.1, <2.0.0a0",
     "iminuit >=2",
@@ -42,7 +42,7 @@ dependencies = [
     "psutil",
     "pyyaml >=5.1",
     "requests",
-    "scikit-learn !=1.4.0", # 1.4.0 breaks with astropy tables, before and after works
+    "scikit-learn <1.4",
     "scipy ~=1.2",
     "tables ~=3.4",
     "tqdm >=4.32",
diff --git a/src/ctapipe/image/extractor.py b/src/ctapipe/image/extractor.py
index 2ba9281e..1d4cfd58 100644
--- a/src/ctapipe/image/extractor.py
+++ b/src/ctapipe/image/extractor.py
@@ -1531,7 +1531,7 @@ def adaptive_centroid(waveforms, peak_index, rel_descend_limit, centroids):
         sum_ += waveforms[j]
         jsum += j * waveforms[j]
         j -= 1
-        if j >= 0 and waveforms[j] > peak_amplitude:
+        if waveforms[j] > peak_amplitude:
             descend_limit = rel_descend_limit * peak_amplitude
 
     j = peak_index + 1
@@ -1539,7 +1539,7 @@ def adaptive_centroid(waveforms, peak_index, rel_descend_limit, centroids):
         sum_ += waveforms[j]
         jsum += j * waveforms[j]
         j += 1
-        if j < n_samples and waveforms[j] > peak_amplitude:
+        if waveforms[j] > peak_amplitude:
             descend_limit = rel_descend_limit * peak_amplitude
 
     if sum_ != 0.0:
@@ -1616,18 +1616,14 @@ class FlashCamExtractor(ImageExtractor):
             tel_id: telescope.camera.readout.sampling_rate.to_value("GHz")
             for tel_id, telescope in subarray.tel.items()
         }
-        self._deconvolution_parameters = {}
 
-    def _get_deconvolution_parameters(self, tel_id):
-        if tel_id not in self._deconvolution_parameters:
-            tel = self.subarray.tel[tel_id]
+        def time_profile_pdf_gen(std_dev: float):
+            if std_dev == 0:
+                return None
+            return scipy.stats.norm(0.0, std_dev).pdf
 
-            def time_profile_pdf_gen(std_dev: float):
-                if std_dev == 0:
-                    return None
-                return scipy.stats.norm(0.0, std_dev).pdf
-
-            self._deconvolution_parameters[tel_id] = deconvolution_parameters(
+        self.deconvolution_pars = {
+            tel_id: deconvolution_parameters(
                 tel.camera,
                 self.upsampling.tel[tel_id],
                 self.window_width.tel[tel_id],
@@ -1636,7 +1632,8 @@ class FlashCamExtractor(ImageExtractor):
                 self.leading_edge_rel_descend_limit.tel[tel_id],
                 time_profile_pdf_gen(self.effective_time_profile_std.tel[tel_id]),
             )
-        return self._deconvolution_parameters[tel_id]
+            for tel_id, tel in subarray.tel.items()
+        }
 
     @staticmethod
     def clip(x, lo=0.0, hi=np.inf):
@@ -1653,7 +1650,7 @@ class FlashCamExtractor(ImageExtractor):
         leading_edge_timing = self.leading_edge_timing.tel[tel_id]
         leading_edge_rel_descend_limit = self.leading_edge_rel_descend_limit.tel[tel_id]
 
-        pole_zeros, gains, shifts, pz2ds = self._get_deconvolution_parameters(tel_id)
+        pole_zeros, gains, shifts, pz2ds = self.deconvolution_pars[tel_id]
         pz, gain, shift, pz2d = pole_zeros[0], gains[0], shifts[0], pz2ds[0]
 
         t_waveforms = deconvolve(waveforms, 0.0, upsampling, pz)
diff --git a/src/ctapipe/visualization/bokeh.py b/src/ctapipe/visualization/bokeh.py
index 0a764ac7..a6300e8f 100644
--- a/src/ctapipe/visualization/bokeh.py
+++ b/src/ctapipe/visualization/bokeh.py
@@ -21,6 +21,7 @@ from bokeh.models import (
 )
 from bokeh.palettes import Greys256, Inferno256, Magma256, Viridis256, d3
 from bokeh.plotting import figure
+from bokeh.transform import transform
 from matplotlib.colors import to_hex
 
 from ..instrument import CameraGeometry, PixelShape
@@ -239,10 +240,10 @@ class BokehPlot(metaclass=ABCMeta):
             else:
                 raise ValueError(f"Unsupported norm {norm}")
 
-        self._color_mapper = norm(self.cmap)
+        self._color_mapper = norm(palette=self.cmap)
         if self._patches is not None:
-            color = dict(transform=self._color_mapper)
-            self._patches.glyph.update(fill_color=color, line_color=color)
+            color = transform("values", self._color_mapper)
+            self._patches.glyph.update(fill_color=color)
 
         if self._color_bar is not None:
             self._color_bar.update(color_mapper=self._color_mapper)
@@ -373,7 +374,7 @@ class CameraDisplay(BokehPlot):
             self.figure.add_tools(TapTool())
         self.datasource.selected.on_change("indices", callback)
 
-    def highlight_pixels(self, pixels, color="g", linewidth=1, alpha=0.75):
+    def highlight_pixels(self, pixels, color="green", linewidth=1, alpha=0.75):
         """
         Highlight the given pixels with a colored line around them
 
@@ -393,7 +394,7 @@ class CameraDisplay(BokehPlot):
         n_pixels = self._geometry.n_pixels
         pixels = np.asanyarray(pixels)
 
-        if pixels.dtype != np.bool:
+        if pixels.dtype != bool:
             selected = np.zeros(n_pixels, dtype=bool)
             selected[pixels] = True
             pixels = selected
@@ -454,7 +455,6 @@ class CameraDisplay(BokehPlot):
             3rd-order moment for directionality if known
         kwargs:
             any MatPlotLib style arguments to pass to the Ellipse patch
-
         """
         ellipse = Ellipse(
             x=centroid[0],
@@ -585,6 +585,15 @@ class ArrayDisplay(BokehPlot):
             frame_name = (frame or subarray.tel_coords.frame).__class__.__name__
             title = f"{subarray.name} ({frame_name})"
 
+        # color by type if no value given
+        if values is None:
+            types = list({str(t) for t in subarray.telescope_types})
+            cmap = cmap or d3["Category10"][10][: len(types)]
+            field = "type"
+        else:
+            cmap = "inferno"
+            field = "values"
+
         super().__init__(
             use_notebook=use_notebook,
             title=title,
@@ -595,15 +604,8 @@ class ArrayDisplay(BokehPlot):
             **figure_kwargs,
         )
 
-        # color by type if no value given
         if values is None:
-            types = list({str(t) for t in subarray.telescope_types})
-            cmap = cmap or d3["Category10"][10][: len(types)]
             self._color_mapper = CategoricalColorMapper(palette=cmap, factors=types)
-            field = "type"
-        else:
-            self.cmap = "inferno"
-            field = "values"
 
         self.frame = frame
         self.subarray = subarray
@@ -617,7 +619,7 @@ class ArrayDisplay(BokehPlot):
             alpha=alpha,
         )
 
-        color = dict(field=field, transform=self._color_mapper)
+        color = transform(field_name=field, transform=self._color_mapper)
         self._patches = self.figure.circle(
             x="x",
             y="y",
@@ -687,7 +689,7 @@ class ArrayDisplay(BokehPlot):
         if self._patches.glyph.fill_color["field"] == "type":
             self.norm = "lin"
             self.cmap = "inferno"
-            color = dict(field="values", transform=self._color_mapper)
+            color = transform(field_name="values", transform=self._color_mapper)
             self._patches.glyph.update(fill_color=color, line_color=color)
 
             # recreate color bar, updating does not work here


Test patch:
diff --git a/src/ctapipe/io/tests/test_hdf5.py b/src/ctapipe/io/tests/test_hdf5.py
index 68156706..59009ce4 100644
--- a/src/ctapipe/io/tests/test_hdf5.py
+++ b/src/ctapipe/io/tests/test_hdf5.py
@@ -865,7 +865,9 @@ def test_write_default_container(cls, tmp_path):
         except ValueError as e:
             # some containers do not have writable members,
             # only subcontainers. For now, ignore them.
-            if "cannot create an empty data type" not in str(e):
+            if "cannot create an empty data type" in str(e):
+                pytest.xfail()
+            else:
                 raise
 
 
diff --git a/src/ctapipe/reco/tests/test_ImPACT.py b/src/ctapipe/reco/tests/test_ImPACT.py
index 35e0fc7c..f586a487 100644
--- a/src/ctapipe/reco/tests/test_ImPACT.py
+++ b/src/ctapipe/reco/tests/test_ImPACT.py
@@ -177,7 +177,7 @@ class TestImPACT:
 
         impact_reco.get_hillas_mean()
 
-        seed, step, limits = create_seed(0.0, 0.0, 0.0, 0.0, 0.5)
+        seed, step, limits = create_seed(0.0, 0.0, 0.0, 0.0, 0.8)
         vals, error, chi2 = impact_reco.minimise(seed, step, limits)
         assert_allclose(vals[4], 1, rtol=0.05)
 
diff --git a/src/ctapipe/visualization/tests/test_bokeh.py b/src/ctapipe/visualization/tests/test_bokeh.py
index 2e1dc1c8..a6ee6906 100644
--- a/src/ctapipe/visualization/tests/test_bokeh.py
+++ b/src/ctapipe/visualization/tests/test_bokeh.py
@@ -55,6 +55,8 @@ def test_camera_image(example_event, example_subarray, tmp_path):
     display.image = np.random.normal(size=geom.n_pixels)
     assert np.all(display.image == image)
 
+    display.highlight_pixels(display.image > 0)
+
     output_path = tmp_path / "test.html"
     output_file(output_path)
     save(display.figure, filename=output_path)