From dd6740a6180508706f0892cc70fdaf98ce031486 Mon Sep 17 00:00:00 2001 From: "mykhailo.dalchenko" Date: Wed, 5 Aug 2026 16:46:34 +0200 Subject: [PATCH 1/3] Add simple pointing calibrator --- src/ctapipe/calib/__init__.py | 2 + src/ctapipe/calib/optics/__init__.py | 3 + src/ctapipe/calib/optics/calibrator.py | 86 +++++++++++ src/ctapipe/calib/optics/tests/__init__.py | 0 .../calib/optics/tests/test_calibrator.py | 137 ++++++++++++++++++ src/ctapipe/tools/process.py | 17 ++- 6 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 src/ctapipe/calib/optics/__init__.py create mode 100644 src/ctapipe/calib/optics/calibrator.py create mode 100644 src/ctapipe/calib/optics/tests/__init__.py create mode 100644 src/ctapipe/calib/optics/tests/test_calibrator.py diff --git a/src/ctapipe/calib/__init__.py b/src/ctapipe/calib/__init__.py index 137430ccba1..9119ba78d3d 100644 --- a/src/ctapipe/calib/__init__.py +++ b/src/ctapipe/calib/__init__.py @@ -4,8 +4,10 @@ """ from .camera import CameraCalibrator, GainSelector +from .optics import PointingCalibrator __all__ = [ "CameraCalibrator", "GainSelector", + "PointingCalibrator", ] diff --git a/src/ctapipe/calib/optics/__init__.py b/src/ctapipe/calib/optics/__init__.py new file mode 100644 index 00000000000..b06ff00dea7 --- /dev/null +++ b/src/ctapipe/calib/optics/__init__.py @@ -0,0 +1,3 @@ +from .calibrator import PointingCalibrator + +__all__ = ["PointingCalibrator"] diff --git a/src/ctapipe/calib/optics/calibrator.py b/src/ctapipe/calib/optics/calibrator.py new file mode 100644 index 00000000000..6af67c074ba --- /dev/null +++ b/src/ctapipe/calib/optics/calibrator.py @@ -0,0 +1,86 @@ +import astropy.units as u +import numpy as np + +from ...containers import TelescopePointingContainer +from ...core import TelescopeComponent + + +class PointingCalibrator(TelescopeComponent): + """ + Calibrates telescope pointing by evaluating pre-interpolated structural + pointing and structural displacement containers from event monitoring data. + """ + + def __call__(self, event) -> None: + """ + Calibrate pointing for all triggered telescopes in an event. + + Parameters + ---------- + event : ctapipe.containers.DataContainer + The event to calibrate. + """ + for tel_id in event.trigger.tels_with_trigger: + if tel_id not in event.monitoring.tel: + continue + + mon = event.monitoring.tel[tel_id] + pointing_container = self._apply_structure_displacement(mon) + if pointing_container is None: + self.log.warning( + "Pointing calibration failed for telescope %s. " + "Skipping pointing calibration for this telescope.", + tel_id, + ) + continue + event.monitoring.tel[tel_id].pointing = pointing_container + + def _apply_structure_displacement(self, mon_tel) -> TelescopePointingContainer: + """ + Apply structural displacement to raw structure pointing. + """ + raw_pointing = mon_tel.structure_pointing + displacement = mon_tel.structure_displacement + if raw_pointing is None: + self.log.warning("No structure pointing data available.") + return None + if displacement is None: + self.log.warning("No structure displacement data available.") + return None + + # Combine raw encoder positions with structural displacement offsets + alt_corr = raw_pointing.altitude + displacement.delta_altitude + az_corr = (raw_pointing.azimuth + displacement.delta_azimuth) % ( + 2 * np.pi * u.rad + ) + + return TelescopePointingContainer( + azimuth=az_corr, + altitude=alt_corr, + ) + + def _apply_camera_displacement(self, event, tel_id): + """ + Apply the camera displacement to the pointing of the telescope. + + Parameters + ---------- + event: ctapipe.containers.DataContainer + The event to calibrate. + tel_id: int + The telescope ID to calibrate. + """ + raise NotImplementedError("_apply_camera_displacement is not yet implemented.") + + def _apply_pointing_correction(self, event, tel_id): + """ + Apply the pointing correction to the pointing of the telescope. + + Parameters + ---------- + event: ctapipe.containers.DataContainer + The event to calibrate. + tel_id: int + The telescope ID to calibrate. + """ + raise NotImplementedError("_apply_pointing_correction is not yet implemented.") diff --git a/src/ctapipe/calib/optics/tests/__init__.py b/src/ctapipe/calib/optics/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ctapipe/calib/optics/tests/test_calibrator.py b/src/ctapipe/calib/optics/tests/test_calibrator.py new file mode 100644 index 00000000000..0c8fcffd890 --- /dev/null +++ b/src/ctapipe/calib/optics/tests/test_calibrator.py @@ -0,0 +1,137 @@ +import astropy.units as u +import pytest + +from ctapipe.calib.optics.calibrator import PointingCalibrator +from ctapipe.containers import ( + ArrayEventContainer, + TelescopeStructureDisplacementContainer, + TelescopeStructurePointingContainer, +) + + +@pytest.fixture +def pointing_calibrator(example_subarray): + return PointingCalibrator(subarray=example_subarray) + + +def test_apply_structure_displacement(pointing_calibrator): + event = ArrayEventContainer() + tel_id = 1 + event.trigger.tels_with_trigger = [tel_id] + + raw_pointing = TelescopeStructurePointingContainer( + azimuth=0.3 * u.rad, + altitude=0.7 * u.rad, + ) + displacement = TelescopeStructureDisplacementContainer( + delta_azimuth=0.2 * u.rad, + delta_altitude=-0.1 * u.rad, + ) + + event.monitoring.tel[tel_id].structure_pointing = raw_pointing + event.monitoring.tel[tel_id].structure_displacement = displacement + + pointing_calibrator(event) + + calibrated = event.monitoring.tel[tel_id].pointing + assert calibrated is not None + assert u.isclose(calibrated.azimuth, 0.5 * u.rad) + assert u.isclose(calibrated.altitude, 0.6 * u.rad) + + +def test_apply_structure_displacement_wraps_azimuth(pointing_calibrator): + event = ArrayEventContainer() + tel_id = 2 + event.trigger.tels_with_trigger = [tel_id] + + raw_pointing = TelescopeStructurePointingContainer( + azimuth=(2 * 3.141592653589793 - 0.2) * u.rad, + altitude=1.0 * u.rad, + ) + displacement = TelescopeStructureDisplacementContainer( + delta_azimuth=0.3 * u.rad, + delta_altitude=0.0 * u.rad, + ) + + event.monitoring.tel[tel_id].structure_pointing = raw_pointing + event.monitoring.tel[tel_id].structure_displacement = displacement + + pointing_calibrator(event) + + calibrated = event.monitoring.tel[tel_id].pointing + assert calibrated is not None + assert u.isclose(calibrated.azimuth, 0.1 * u.rad) + assert u.isclose(calibrated.altitude, 1.0 * u.rad) + + +def test_missing_structure_pointing_logs_warning(pointing_calibrator, caplog): + event = ArrayEventContainer() + tel_id = 3 + event.trigger.tels_with_trigger = [tel_id] + + event.monitoring.tel[ + tel_id + ].structure_displacement = TelescopeStructureDisplacementContainer( + delta_azimuth=0.1 * u.rad, + delta_altitude=0.0 * u.rad, + ) + + pointing_calibrator(event) + + assert event.monitoring.tel[tel_id].pointing is None + assert "No structure pointing data available." in caplog.text + + +def test_missing_structure_displacement_logs_warning(pointing_calibrator, caplog): + event = ArrayEventContainer() + tel_id = 4 + event.trigger.tels_with_trigger = [tel_id] + + event.monitoring.tel[ + tel_id + ].structure_pointing = TelescopeStructurePointingContainer( + azimuth=0.1 * u.rad, + altitude=0.2 * u.rad, + ) + + pointing_calibrator(event) + + assert event.monitoring.tel[tel_id].pointing is None + assert "No structure displacement data available." in caplog.text + + +def test_missing_tel_in_monitoring_is_skipped(pointing_calibrator): + event = ArrayEventContainer() + tel_id = 5 + event.trigger.tels_with_trigger = [tel_id] + + pointing_calibrator(event) + + assert tel_id not in event.monitoring.tel + + +def test_only_matching_telescopes_are_calibrated(pointing_calibrator): + event = ArrayEventContainer() + tels = [1, 2] + event.trigger.tels_with_trigger = tels + + for tel_id in tels: + event.monitoring.tel[ + tel_id + ].structure_pointing = TelescopeStructurePointingContainer( + azimuth=0.1 * u.rad, + altitude=0.2 * u.rad, + ) + event.monitoring.tel[ + tel_id + ].structure_displacement = TelescopeStructureDisplacementContainer( + delta_azimuth=0.0 * u.rad, + delta_altitude=0.0 * u.rad, + ) + + event.monitoring.tel[2].structure_pointing = None + + pointing_calibrator(event) + + assert event.monitoring.tel[1].pointing is not None + assert event.monitoring.tel[2].pointing is None diff --git a/src/ctapipe/tools/process.py b/src/ctapipe/tools/process.py index cdee505c201..3136c4e3ae3 100644 --- a/src/ctapipe/tools/process.py +++ b/src/ctapipe/tools/process.py @@ -7,8 +7,8 @@ from tqdm.auto import tqdm -from ..calib import CameraCalibrator, GainSelector -from ..core import QualityQuery, Tool, ToolConfigurationError +from ..calib import CameraCalibrator, GainSelector, PointingCalibrator +from ..core import QualityQuery, Tool from ..core.traits import Bool, ComponentName, List, classes_with_traits, flag from ..exceptions import InputMissing from ..image import ImageCleaner, ImageModifier, ImageProcessor, WaveformModifier @@ -233,16 +233,16 @@ def setup(self): f"Please make sure the '{mon_source_name}' and its input " f"are suitable for calibrating the data you are processing." ) - self.log.critical(msg) - raise ToolConfigurationError(msg) - # Append the monitoring source to the list if it has compatible monitoring types + self.log.warning(msg) + # Append the monitoring source to the list self._monitoring_sources.append(mon_source) if self.add_nsb_in_waveforms: self.waveform_modifier = WaveformModifier(parent=self, subarray=subarray) self.software_trigger = SoftwareTrigger(parent=self, subarray=subarray) - self.calibrate = CameraCalibrator(parent=self, subarray=subarray) + self.calibrate_camera = CameraCalibrator(parent=self, subarray=subarray) + self.calibrate_pointing = PointingCalibrator(parent=self, subarray=subarray) self.process_images = ImageProcessor(subarray=subarray, parent=self) self.process_shower = ShowerProcessor( subarray=subarray, @@ -369,7 +369,10 @@ def start(self): mon_source.fill_monitoring_container(event) if self.should_calibrate: - self.calibrate(event) + self.calibrate_camera(event) + # Pointing calibration is only applied to real data, not simulations + if not self.event_source.metadata["is_simulation"]: + self.calibrate_pointing(event) if self.should_compute_dl1: self.process_images(event) From 8de286b07f9f2d1aa4162a20609ac078db9d543a Mon Sep 17 00:00:00 2001 From: "mykhailo.dalchenko" Date: Thu, 6 Aug 2026 11:31:04 +0200 Subject: [PATCH 2/3] Use raw structure pointing if structure displacement is not available. --- src/ctapipe/calib/optics/calibrator.py | 34 +++++++++++-------- .../calib/optics/tests/test_calibrator.py | 14 ++++++-- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/ctapipe/calib/optics/calibrator.py b/src/ctapipe/calib/optics/calibrator.py index 6af67c074ba..754042204ea 100644 --- a/src/ctapipe/calib/optics/calibrator.py +++ b/src/ctapipe/calib/optics/calibrator.py @@ -1,7 +1,6 @@ import astropy.units as u import numpy as np -from ...containers import TelescopePointingContainer from ...core import TelescopeComponent @@ -25,40 +24,45 @@ def __call__(self, event) -> None: continue mon = event.monitoring.tel[tel_id] - pointing_container = self._apply_structure_displacement(mon) + pointing_container = mon.structure_pointing if pointing_container is None: self.log.warning( - "Pointing calibration failed for telescope %s. " + "Structure pointing data not available for telescope %s. " "Skipping pointing calibration for this telescope.", tel_id, ) continue + + if not self._apply_structure_displacement(pointing_container, mon): + self.log.warning( + "Structure displacement data not available for telescope %s. " + "Using raw structure pointing.", + tel_id, + ) event.monitoring.tel[tel_id].pointing = pointing_container - def _apply_structure_displacement(self, mon_tel) -> TelescopePointingContainer: + def _apply_structure_displacement(self, pointing_container, mon_tel) -> bool: """ Apply structural displacement to raw structure pointing. """ - raw_pointing = mon_tel.structure_pointing displacement = mon_tel.structure_displacement - if raw_pointing is None: - self.log.warning("No structure pointing data available.") - return None if displacement is None: - self.log.warning("No structure displacement data available.") - return None - + self.log.warning( + "No structure displacement data available, using raw structure pointing." + ) + return False # Combine raw encoder positions with structural displacement offsets - alt_corr = raw_pointing.altitude + displacement.delta_altitude - az_corr = (raw_pointing.azimuth + displacement.delta_azimuth) % ( + alt_corr = pointing_container.altitude + displacement.delta_altitude + az_corr = (pointing_container.azimuth + displacement.delta_azimuth) % ( 2 * np.pi * u.rad ) - - return TelescopePointingContainer( + pointing_container.update( azimuth=az_corr, altitude=alt_corr, ) + return True + def _apply_camera_displacement(self, event, tel_id): """ Apply the camera displacement to the pointing of the telescope. diff --git a/src/ctapipe/calib/optics/tests/test_calibrator.py b/src/ctapipe/calib/optics/tests/test_calibrator.py index 0c8fcffd890..31b2ee34261 100644 --- a/src/ctapipe/calib/optics/tests/test_calibrator.py +++ b/src/ctapipe/calib/optics/tests/test_calibrator.py @@ -79,7 +79,9 @@ def test_missing_structure_pointing_logs_warning(pointing_calibrator, caplog): pointing_calibrator(event) assert event.monitoring.tel[tel_id].pointing is None - assert "No structure pointing data available." in caplog.text + assert ( + f"Structure pointing data not available for telescope {tel_id}." in caplog.text + ) def test_missing_structure_displacement_logs_warning(pointing_calibrator, caplog): @@ -96,8 +98,14 @@ def test_missing_structure_displacement_logs_warning(pointing_calibrator, caplog pointing_calibrator(event) - assert event.monitoring.tel[tel_id].pointing is None - assert "No structure displacement data available." in caplog.text + calibrated = event.monitoring.tel[tel_id].pointing + assert calibrated is not None + assert u.isclose(calibrated.azimuth, 0.1 * u.rad) + assert u.isclose(calibrated.altitude, 0.2 * u.rad) + assert ( + f"Structure displacement data not available for telescope {tel_id}." + in caplog.text + ) def test_missing_tel_in_monitoring_is_skipped(pointing_calibrator): From 448071fc6975a695e2a8adec602b97654825ef17 Mon Sep 17 00:00:00 2001 From: "mykhailo.dalchenko" Date: Thu, 6 Aug 2026 11:56:42 +0200 Subject: [PATCH 3/3] add changelog --- docs/changes/3066.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changes/3066.feature.rst diff --git a/docs/changes/3066.feature.rst b/docs/changes/3066.feature.rst new file mode 100644 index 00000000000..409cb1d8a55 --- /dev/null +++ b/docs/changes/3066.feature.rst @@ -0,0 +1 @@ +Add basic pointing calibrator that combines the raw structure pointing with structure displacement corrections.