ctao-calibpipe 0.3.0rc2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. calibpipe/__init__.py +5 -0
  2. calibpipe/_dev_version/__init__.py +9 -0
  3. calibpipe/_version.py +34 -0
  4. calibpipe/atmosphere/__init__.py +1 -0
  5. calibpipe/atmosphere/atmosphere_containers.py +109 -0
  6. calibpipe/atmosphere/meteo_data_handlers.py +485 -0
  7. calibpipe/atmosphere/models/README.md +14 -0
  8. calibpipe/atmosphere/models/__init__.py +1 -0
  9. calibpipe/atmosphere/models/macobac.ecsv +23 -0
  10. calibpipe/atmosphere/models/reference_MDPs/__init__.py +1 -0
  11. calibpipe/atmosphere/models/reference_MDPs/ref_density_at_15km_ctao-north_intermediate.ecsv +8 -0
  12. calibpipe/atmosphere/models/reference_MDPs/ref_density_at_15km_ctao-north_summer.ecsv +8 -0
  13. calibpipe/atmosphere/models/reference_MDPs/ref_density_at_15km_ctao-north_winter.ecsv +8 -0
  14. calibpipe/atmosphere/models/reference_MDPs/ref_density_at_15km_ctao-south_summer.ecsv +8 -0
  15. calibpipe/atmosphere/models/reference_MDPs/ref_density_at_15km_ctao-south_winter.ecsv +8 -0
  16. calibpipe/atmosphere/models/reference_atmospheres/__init__.py +1 -0
  17. calibpipe/atmosphere/models/reference_atmospheres/reference_atmo_model_v0_ctao-north_intermediate.ecsv +73 -0
  18. calibpipe/atmosphere/models/reference_atmospheres/reference_atmo_model_v0_ctao-north_summer.ecsv +73 -0
  19. calibpipe/atmosphere/models/reference_atmospheres/reference_atmo_model_v0_ctao-north_winter.ecsv +73 -0
  20. calibpipe/atmosphere/models/reference_atmospheres/reference_atmo_model_v0_ctao-south_summer.ecsv +73 -0
  21. calibpipe/atmosphere/models/reference_atmospheres/reference_atmo_model_v0_ctao-south_winter.ecsv +73 -0
  22. calibpipe/atmosphere/models/reference_rayleigh_scattering_profiles/__init__.py +1 -0
  23. calibpipe/atmosphere/models/reference_rayleigh_scattering_profiles/reference_rayleigh_extinction_profile_v0_ctao-north_intermediate.ecsv +857 -0
  24. calibpipe/atmosphere/models/reference_rayleigh_scattering_profiles/reference_rayleigh_extinction_profile_v0_ctao-north_summer.ecsv +857 -0
  25. calibpipe/atmosphere/models/reference_rayleigh_scattering_profiles/reference_rayleigh_extinction_profile_v0_ctao-north_winter.ecsv +857 -0
  26. calibpipe/atmosphere/models/reference_rayleigh_scattering_profiles/reference_rayleigh_extinction_profile_v0_ctao-south_summer.ecsv +857 -0
  27. calibpipe/atmosphere/models/reference_rayleigh_scattering_profiles/reference_rayleigh_extinction_profile_v0_ctao-south_winter.ecsv +857 -0
  28. calibpipe/atmosphere/templates/request_templates/__init__.py +1 -0
  29. calibpipe/atmosphere/templates/request_templates/copernicus.json +11 -0
  30. calibpipe/atmosphere/templates/request_templates/gdas.json +12 -0
  31. calibpipe/core/__init__.py +39 -0
  32. calibpipe/core/common_metadata_containers.py +198 -0
  33. calibpipe/core/exceptions.py +87 -0
  34. calibpipe/database/__init__.py +24 -0
  35. calibpipe/database/adapter/__init__.py +23 -0
  36. calibpipe/database/adapter/adapter.py +80 -0
  37. calibpipe/database/adapter/database_containers/__init__.py +63 -0
  38. calibpipe/database/adapter/database_containers/atmosphere.py +199 -0
  39. calibpipe/database/adapter/database_containers/common_metadata.py +150 -0
  40. calibpipe/database/adapter/database_containers/container_map.py +59 -0
  41. calibpipe/database/adapter/database_containers/observatory.py +61 -0
  42. calibpipe/database/adapter/database_containers/table_version_manager.py +39 -0
  43. calibpipe/database/adapter/database_containers/throughput.py +30 -0
  44. calibpipe/database/adapter/database_containers/version_control.py +17 -0
  45. calibpipe/database/connections/__init__.py +28 -0
  46. calibpipe/database/connections/calibpipe_database.py +60 -0
  47. calibpipe/database/connections/postgres_utils.py +97 -0
  48. calibpipe/database/connections/sql_connection.py +103 -0
  49. calibpipe/database/connections/user_confirmation.py +19 -0
  50. calibpipe/database/interfaces/__init__.py +71 -0
  51. calibpipe/database/interfaces/hashable_row_data.py +54 -0
  52. calibpipe/database/interfaces/queries.py +180 -0
  53. calibpipe/database/interfaces/sql_column_info.py +67 -0
  54. calibpipe/database/interfaces/sql_metadata.py +6 -0
  55. calibpipe/database/interfaces/sql_table_info.py +131 -0
  56. calibpipe/database/interfaces/table_handler.py +333 -0
  57. calibpipe/database/interfaces/types.py +96 -0
  58. calibpipe/telescope/throughput/containers.py +66 -0
  59. calibpipe/tests/conftest.py +274 -0
  60. calibpipe/tests/data/atmosphere/molecular_atmosphere/__init__.py +0 -0
  61. calibpipe/tests/data/atmosphere/molecular_atmosphere/contemporary_MDP.ecsv +34 -0
  62. calibpipe/tests/data/atmosphere/molecular_atmosphere/macobac.csv +852 -0
  63. calibpipe/tests/data/atmosphere/molecular_atmosphere/macobac.ecsv +23 -0
  64. calibpipe/tests/data/atmosphere/molecular_atmosphere/merged_file.ecsv +1082 -0
  65. calibpipe/tests/data/atmosphere/molecular_atmosphere/meteo_data_copernicus.ecsv +1082 -0
  66. calibpipe/tests/data/atmosphere/molecular_atmosphere/meteo_data_gdas.ecsv +66 -0
  67. calibpipe/tests/data/atmosphere/molecular_atmosphere/observatory_configurations.json +71 -0
  68. calibpipe/tests/data/utils/__init__.py +0 -0
  69. calibpipe/tests/data/utils/meteo_data_winter_and_summer.ecsv +12992 -0
  70. calibpipe/tests/test_conftest_data.py +200 -0
  71. calibpipe/tests/unittests/array/test_cross_calibration.py +412 -0
  72. calibpipe/tests/unittests/atmosphere/astral_testing.py +107 -0
  73. calibpipe/tests/unittests/atmosphere/test_meteo_data_handler.py +775 -0
  74. calibpipe/tests/unittests/atmosphere/test_molecular_atmosphere.py +327 -0
  75. calibpipe/tests/unittests/database/test_table_handler.py +163 -0
  76. calibpipe/tests/unittests/database/test_types.py +38 -0
  77. calibpipe/tests/unittests/telescope/camera/test_calculate_camcalib_coefficients.py +456 -0
  78. calibpipe/tests/unittests/telescope/camera/test_produce_camcalib_test_data.py +37 -0
  79. calibpipe/tests/unittests/telescope/throughput/test_muon_throughput_calibrator.py +693 -0
  80. calibpipe/tests/unittests/test_bootstrap_db.py +79 -0
  81. calibpipe/tests/unittests/utils/test_observatory.py +309 -0
  82. calibpipe/tools/atmospheric_base_tool.py +78 -0
  83. calibpipe/tools/atmospheric_model_db_loader.py +181 -0
  84. calibpipe/tools/basic_tool_with_db.py +38 -0
  85. calibpipe/tools/camcalib_test_data.py +374 -0
  86. calibpipe/tools/camera_calibrator.py +462 -0
  87. calibpipe/tools/contemporary_mdp_producer.py +87 -0
  88. calibpipe/tools/init_db.py +37 -0
  89. calibpipe/tools/macobac_calculator.py +82 -0
  90. calibpipe/tools/molecular_atmospheric_model_producer.py +197 -0
  91. calibpipe/tools/muon_throughput_calculator.py +219 -0
  92. calibpipe/tools/observatory_data_db_loader.py +71 -0
  93. calibpipe/tools/reference_atmospheric_model_selector.py +201 -0
  94. calibpipe/tools/telescope_cross_calibration_calculator.py +721 -0
  95. calibpipe/utils/__init__.py +10 -0
  96. calibpipe/utils/observatory.py +486 -0
  97. calibpipe/utils/observatory_containers.py +26 -0
  98. calibpipe/version.py +24 -0
  99. ctao_calibpipe-0.3.0rc2.dist-info/METADATA +92 -0
  100. ctao_calibpipe-0.3.0rc2.dist-info/RECORD +105 -0
  101. ctao_calibpipe-0.3.0rc2.dist-info/WHEEL +5 -0
  102. ctao_calibpipe-0.3.0rc2.dist-info/entry_points.txt +12 -0
  103. ctao_calibpipe-0.3.0rc2.dist-info/licenses/AUTHORS.md +13 -0
  104. ctao_calibpipe-0.3.0rc2.dist-info/licenses/LICENSE +21 -0
  105. ctao_calibpipe-0.3.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,200 @@
1
+ """
2
+ Test for CalibPipe test data download and caching functionality.
3
+
4
+ This test verifies that all test files can be downloaded and cached properly
5
+ using the conftest.py infrastructure.
6
+ """
7
+
8
+ from calibpipe.tests.conftest import (
9
+ DEFAULT_CALIBPIPE_URL,
10
+ FILE_PATH_MAPPING,
11
+ TEST_FILES,
12
+ )
13
+ from ctapipe.utils.datasets import get_dataset_path
14
+
15
+
16
+ class TestCalibPipeTestData:
17
+ """Test class for CalibPipe test data functionality."""
18
+
19
+ def test_download_all_test_files(self):
20
+ """
21
+ Test that all test files in TEST_FILES can be downloaded and cached.
22
+
23
+ This test will:
24
+ 1. Download each file listed in TEST_FILES
25
+ 2. Verify the file exists after download
26
+ 3. Verify the file has non-zero size
27
+ 4. Test that subsequent calls use the cached version
28
+ """
29
+ downloaded_files = {}
30
+
31
+ for file_path in TEST_FILES:
32
+ print(f"Downloading and testing: {file_path}")
33
+
34
+ # Get the server path from the mapping and download
35
+ server_path = FILE_PATH_MAPPING[file_path]
36
+ cached_file = get_dataset_path(server_path, url=DEFAULT_CALIBPIPE_URL)
37
+
38
+ # Verify the file exists
39
+ assert (
40
+ cached_file.exists()
41
+ ), f"Downloaded file does not exist: {cached_file}"
42
+
43
+ # Verify the file has content
44
+ file_size = cached_file.stat().st_size
45
+ assert (
46
+ file_size > 0
47
+ ), f"Downloaded file is empty: {cached_file} (size: {file_size})"
48
+
49
+ # Store for further verification
50
+ downloaded_files[file_path] = {"path": cached_file, "size": file_size}
51
+
52
+ print(f" ✓ Downloaded to: {cached_file}")
53
+ print(f" ✓ File size: {file_size:,} bytes")
54
+
55
+ # Test that all files are accessible
56
+ assert len(downloaded_files) == len(
57
+ TEST_FILES
58
+ ), f"Expected {len(TEST_FILES)} files, got {len(downloaded_files)}"
59
+
60
+ print(
61
+ f"\n✓ Successfully downloaded and verified {len(downloaded_files)} test files"
62
+ )
63
+
64
+ # Test caching by downloading again and ensuring we get the same paths
65
+ print("\nTesting cache functionality...")
66
+ for file_path in TEST_FILES[:3]: # Test first 3 files for caching
67
+ server_path = FILE_PATH_MAPPING[file_path]
68
+ cached_file_again = get_dataset_path(server_path, url=DEFAULT_CALIBPIPE_URL)
69
+ original_file = downloaded_files[file_path]["path"]
70
+
71
+ assert (
72
+ cached_file_again == original_file
73
+ ), f"Cache miss: got different path for {file_path}"
74
+ print(f" ✓ Cache hit for: {file_path}")
75
+
76
+ print("✓ Cache functionality verified")
77
+
78
+ def test_specific_file_types(self):
79
+ """Test specific file types and their expected characteristics."""
80
+
81
+ # Test HDF5 files
82
+ h5_files = [f for f in TEST_FILES if f.endswith(".h5")]
83
+ for h5_file in h5_files:
84
+ server_path = FILE_PATH_MAPPING[h5_file]
85
+ cached_file = get_dataset_path(server_path, url=DEFAULT_CALIBPIPE_URL)
86
+
87
+ # HDF5 files should have the HDF5 signature
88
+ with open(cached_file, "rb") as f:
89
+ header = f.read(8)
90
+ assert header.startswith(
91
+ b"\x89HDF"
92
+ ), f"File {h5_file} does not appear to be a valid HDF5 file"
93
+
94
+ print(f" ✓ Verified HDF5 format: {h5_file}")
95
+
96
+ # Test gzipped files
97
+ gz_files = [f for f in TEST_FILES if f.endswith(".gz")]
98
+ for gz_file in gz_files:
99
+ server_path = FILE_PATH_MAPPING[gz_file]
100
+ cached_file = get_dataset_path(server_path, url=DEFAULT_CALIBPIPE_URL)
101
+
102
+ # Gzipped files should have the gzip magic number
103
+ with open(cached_file, "rb") as f:
104
+ header = f.read(2)
105
+ assert (
106
+ header == b"\x1f\x8b"
107
+ ), f"File {gz_file} does not appear to be a valid gzip file"
108
+
109
+ print(f" ✓ Verified gzip format: {gz_file}")
110
+
111
+ def test_file_categories(self):
112
+ """Test that files are properly categorized by their directory structure."""
113
+
114
+ categories = {"array": [], "telescope/throughput": [], "telescope/camera": []}
115
+
116
+ for file_path in TEST_FILES:
117
+ if file_path.startswith("array/"):
118
+ categories["array"].append(file_path)
119
+ elif file_path.startswith("telescope/throughput/"):
120
+ categories["telescope/throughput"].append(file_path)
121
+ elif file_path.startswith("telescope/camera/"):
122
+ categories["telescope/camera"].append(file_path)
123
+
124
+ # Verify we have files in each category
125
+ assert len(categories["array"]) > 0, "No array test files found"
126
+ assert (
127
+ len(categories["telescope/throughput"]) > 0
128
+ ), "No throughput test files found"
129
+ assert len(categories["telescope/camera"]) > 0, "No camera test files found"
130
+
131
+ print(f"✓ Array files: {len(categories['array'])}")
132
+ print(f"✓ Throughput files: {len(categories['telescope/throughput'])}")
133
+ print(f"✓ Camera files: {len(categories['telescope/camera'])}")
134
+
135
+ def test_fixtures_work(
136
+ self,
137
+ cross_calibration_dl2_file,
138
+ lst_muon_table_file,
139
+ flatfield_file,
140
+ pedestal_file,
141
+ ):
142
+ """Test that the pytest fixtures work correctly."""
143
+
144
+ test_fixtures = [
145
+ ("cross_calibration_dl2_file", cross_calibration_dl2_file),
146
+ ("lst_muon_table_file", lst_muon_table_file),
147
+ ("flatfield_file", flatfield_file),
148
+ ("pedestal_file", pedestal_file),
149
+ ]
150
+
151
+ for fixture_name, fixture_path in test_fixtures:
152
+ assert (
153
+ fixture_path.exists()
154
+ ), f"Fixture {fixture_name} does not exist: {fixture_path}"
155
+ assert fixture_path.stat().st_size > 0, f"Fixture {fixture_name} is empty"
156
+ print(f" ✓ Fixture {fixture_name}: {fixture_path}")
157
+
158
+ print("✓ All fixtures are working correctly")
159
+
160
+ def test_parametrized_fixture(self, calibpipe_dl1_file):
161
+ """Test the parametrized fixture for DL1 files."""
162
+ assert (
163
+ calibpipe_dl1_file.exists()
164
+ ), f"Parametrized DL1 file does not exist: {calibpipe_dl1_file}"
165
+ assert (
166
+ calibpipe_dl1_file.stat().st_size > 0
167
+ ), f"Parametrized DL1 file is empty: {calibpipe_dl1_file}"
168
+
169
+ # Check that the filename contains one of the expected modes
170
+ filename = calibpipe_dl1_file.name
171
+ expected_modes = ["single_chunk", "same_chunks", "different_chunks"]
172
+ assert any(
173
+ mode in filename for mode in expected_modes
174
+ ), f"DL1 filename {filename} doesn't contain expected mode"
175
+
176
+ print(f" ✓ Parametrized DL1 file: {calibpipe_dl1_file}")
177
+
178
+
179
+ if __name__ == "__main__":
180
+ # Allow running this test directly for quick verification
181
+ import sys
182
+
183
+ print("Running CalibPipe test data download verification...")
184
+ print(f"Test data URL: {DEFAULT_CALIBPIPE_URL}")
185
+ print(f"Number of test files: {len(TEST_FILES)}")
186
+ print("=" * 60)
187
+
188
+ test_instance = TestCalibPipeTestData()
189
+
190
+ try:
191
+ test_instance.test_download_all_test_files()
192
+ test_instance.test_specific_file_types()
193
+ test_instance.test_file_categories()
194
+ print("\n" + "=" * 60)
195
+ print(
196
+ "✅ All tests passed! Test data download and caching is working correctly."
197
+ )
198
+ except Exception as e:
199
+ print(f"\n❌ Test failed: {e}")
200
+ sys.exit(1)
@@ -0,0 +1,412 @@
1
+ import logging
2
+ from enum import Enum
3
+ from unittest.mock import MagicMock
4
+
5
+ import astropy.units as u
6
+ import pytest
7
+ from astropy.table import Table
8
+ from calibpipe.tools.telescope_cross_calibration_calculator import (
9
+ CalculateCrossCalibration,
10
+ RelativeThroughputFitter,
11
+ )
12
+ from ctapipe.instrument import SubarrayDescription
13
+ from ctapipe.io import read_table
14
+ from traitlets.config import Config
15
+
16
+ logger = logging.getLogger("calibpipe.application")
17
+
18
+
19
+ class TestTelescopeCrossCalibration:
20
+ @pytest.fixture(scope="class")
21
+ def test_calibrator(self, cross_calibration_dl2_file):
22
+ test_config = {
23
+ "CalculateCrossCalibration": {
24
+ "input_url": str(cross_calibration_dl2_file),
25
+ "event_filters": {
26
+ "min_gammaness": 0.5,
27
+ },
28
+ "reconstruction_algorithm": "RandomForest",
29
+ "RelativeThroughputFitter": {
30
+ "throughput_normalization": [
31
+ ["type", "LST*", 1.0],
32
+ ["type", "MST*", 1.0],
33
+ ["type", "SST*", 1.0],
34
+ ],
35
+ "reference_telescopes": [
36
+ ["type", "LST*", 1],
37
+ ["type", "MST*", 5],
38
+ ["type", "SST*", 37],
39
+ ],
40
+ },
41
+ "PairFinder": {
42
+ "max_impact_distance": [
43
+ ["type", "LST*", 125.0],
44
+ ["type", "MST*", 125.0],
45
+ ["type", "SST*", 225.0],
46
+ ],
47
+ },
48
+ },
49
+ }
50
+ tool = CalculateCrossCalibration(config=Config(test_config))
51
+
52
+ return tool
53
+
54
+ @pytest.fixture()
55
+ def test_fitter_config(self):
56
+ fitter_config = {
57
+ "RelativeThroughputFitter": {
58
+ "throughput_normalization": [
59
+ ["type", "LST*", 1.0],
60
+ ["type", "MST*", 1.0],
61
+ ["type", "SST*", 1.0],
62
+ ],
63
+ "reference_telescopes": [
64
+ ["type", "LST*", 1],
65
+ ["type", "MST*", 5],
66
+ ["type", "SST*", 37],
67
+ ],
68
+ },
69
+ }
70
+ return Config(fitter_config)
71
+
72
+ @pytest.fixture()
73
+ def mock_minuit(self):
74
+ m = MagicMock()
75
+ m.values = {"x0": 0.74, "x1": 0.75, "x2": 0.76}
76
+ m.errors = {"x0": 1e-6, "x1": 2e-6, "x2": 3e-6}
77
+ return m
78
+
79
+ @pytest.mark.verifies_usecase("UC-120-2.3")
80
+ def test_create_telescope_pairs(self, test_calibrator):
81
+ test_calibrator.setup()
82
+ telescope_pairs = test_calibrator.pair_finder.find_pairs()
83
+ assert "MST" in telescope_pairs
84
+ assert len(telescope_pairs["MST"]) > 0
85
+ assert "SST" in telescope_pairs
86
+ assert len(telescope_pairs["SST"]) > 0
87
+ telescope_pairs = test_calibrator.pair_finder.find_pairs(by_tel_type=False)
88
+ assert "ALL" in telescope_pairs
89
+ assert len(telescope_pairs["ALL"]) > 0
90
+
91
+ @pytest.mark.verifies_usecase("UC-120-2.3")
92
+ def test_distance(self, test_calibrator):
93
+ # if the requested maximum telescope distance is too small, no tel pair should be returned
94
+ updated_pair_finder_config = {
95
+ "CalculateCrossCalibration": {
96
+ "PairFinder": {
97
+ "max_impact_distance": [
98
+ ["type", "LST*", 10.0],
99
+ ["type", "MST*", 10.0],
100
+ ["type", "SST*", 10.0],
101
+ ],
102
+ },
103
+ },
104
+ }
105
+
106
+ test_calibrator.update_config(Config(updated_pair_finder_config))
107
+ test_calibrator.setup()
108
+
109
+ telescope_pairs = test_calibrator.pair_finder.find_pairs()
110
+ assert all(
111
+ isinstance(value, set) and len(value) == 0
112
+ for value in telescope_pairs.values()
113
+ )
114
+
115
+ @pytest.mark.verifies_usecase("UC-120-2.3")
116
+ def test_no_reverse_pairs(self, test_calibrator):
117
+ # Ensure the reverse pair (j, i) is not present
118
+ test_calibrator.setup()
119
+ telescope_pairs = test_calibrator.pair_finder.find_pairs()
120
+
121
+ for telescope_type, pairs in telescope_pairs.items():
122
+ for i, j in pairs:
123
+ assert (
124
+ (j, i) not in pairs
125
+ ), f"Reverse pair ({j}, {i}) exists in {telescope_type} pairs"
126
+
127
+ @pytest.mark.verifies_usecase("UC-120-2.3")
128
+ def test_allowed_telescope_names(self, test_calibrator):
129
+ # Ensure all keys in the dictionary belong to the allowed names
130
+ allowed_telescope_names = {"MST", "SST"}
131
+ test_calibrator.setup()
132
+ telescope_pairs = test_calibrator.pair_finder.find_pairs()
133
+ for telescope_name in telescope_pairs.keys():
134
+ assert (
135
+ telescope_name in allowed_telescope_names
136
+ ), f"Unexpected telescope type '{telescope_name}' found in the output"
137
+
138
+ @pytest.mark.verifies_usecase("UC-120-2.3")
139
+ def test_maximum_telescope_pairs(self, test_calibrator):
140
+ updated_pair_finder_config = {
141
+ "CalculateCrossCalibration": {
142
+ "PairFinder": {
143
+ "max_impact_distance": [
144
+ ["type", "LST*", 150000.0],
145
+ ["type", "MST*", 150000.0],
146
+ ["type", "SST*", 150000.0],
147
+ ],
148
+ },
149
+ },
150
+ }
151
+
152
+ test_calibrator.update_config(Config(updated_pair_finder_config))
153
+ test_calibrator.setup()
154
+
155
+ telescope_pairs = test_calibrator.pair_finder.find_pairs()
156
+
157
+ array = read_table(
158
+ test_calibrator.input_url,
159
+ "configuration/instrument/subarray/layout",
160
+ )
161
+ telescope_types = [str(t).strip() for t in array["type"] if t]
162
+ for tel_type in set(telescope_types):
163
+ tel_type_counts = (array["type"] == tel_type).sum()
164
+ if tel_type_counts > 0:
165
+ assert (
166
+ len(telescope_pairs[tel_type])
167
+ == (tel_type_counts * (tel_type_counts - 1)) // 2
168
+ )
169
+
170
+ @pytest.mark.verifies_usecase("UC-120-2.3")
171
+ def test_merge_tables_success(self, test_calibrator):
172
+ """Test successful merging of tables for telescope pairs."""
173
+ telescope_pairs = {"SST": [(37, 39)], "MST": [(6, 12)]}
174
+ result = test_calibrator.merge_tables(telescope_pairs)
175
+
176
+ assert (37, 39) in result["SST"]
177
+ assert (6, 12) in result["MST"]
178
+ assert len(result["SST"][(37, 39)]) > 0 # Ensure merged table is not empty
179
+
180
+ @pytest.mark.verifies_usecase("UC-120-2.3")
181
+ def test_merge_tables_empty_input(self, test_calibrator):
182
+ """Test merge_tables returns empty dict when given an empty input."""
183
+ telescope_pairs = {}
184
+ result = test_calibrator.merge_tables(telescope_pairs)
185
+ assert result == {}
186
+
187
+ @pytest.mark.verifies_usecase("UC-120-2.3")
188
+ def test_merge_tables_missing_data(self, test_calibrator):
189
+ """Test `merge_tables` handles missing telescope data correctly."""
190
+ telescope_pairs = {"SST": [(37, 39), (42, 143)], "MST": [(5, 6), (6, 120000)]}
191
+ result = test_calibrator.merge_tables(telescope_pairs)
192
+ assert set(result["MST"].keys()) == {(5, 6)}
193
+
194
+ @pytest.mark.verifies_usecase("UC-120-2.3")
195
+ def test_inter_calibration_result_format(
196
+ self, test_fitter_config, cross_calibration_dl2_file
197
+ ):
198
+ data_subsystem = {
199
+ (5, 6): {"mean_asymmetry": 0.1, "mean_uncertainty": 0.01},
200
+ (6, 8): {"mean_asymmetry": -0.05, "mean_uncertainty": 0.02},
201
+ }
202
+
203
+ measured_telescopes = set(
204
+ tel_id for (i, j), entry in data_subsystem.items() for tel_id in (i, j)
205
+ )
206
+
207
+ subarray = SubarrayDescription.read(cross_calibration_dl2_file)
208
+
209
+ test_fitter = RelativeThroughputFitter(
210
+ subarray=subarray.select_subarray(tel_ids=measured_telescopes),
211
+ config=test_fitter_config,
212
+ )
213
+
214
+ result = test_fitter.fit("MST", data_subsystem)
215
+ assert "MST" in result
216
+ assert isinstance(result["MST"], dict)
217
+ assert set(result["MST"].keys()) == {"5", "6", "8"}
218
+ assert all(isinstance(v, tuple) and len(v) == 2 for v in result["MST"].values())
219
+
220
+ @pytest.mark.verifies_usecase("UC-120-2.3")
221
+ def test_event_selection_with_multiple_filters(self, test_calibrator, monkeypatch):
222
+ test_calibrator.setup()
223
+ test_calibrator.event_filters = {
224
+ "min_gammaness": 0.5,
225
+ "min_energy": u.Quantity(0.0, u.GeV),
226
+ }
227
+ gamma_set = {(1, 100), (2, 200), (3, 300)}
228
+ energy_set = {(1, 100), (3, 300)}
229
+
230
+ monkeypatch.setattr(
231
+ test_calibrator,
232
+ "_apply_min_gammaness",
233
+ lambda tel_id1, tel_id2, threshold: gamma_set,
234
+ )
235
+ monkeypatch.setattr(
236
+ test_calibrator,
237
+ "_apply_min_energy",
238
+ lambda tel_id1, tel_id2, threshold: energy_set,
239
+ )
240
+ merged_table = Table({"obs_id": [1, 2, 3, 4], "event_id": [100, 200, 300, 400]})
241
+
242
+ filtered = test_calibrator.event_selection(merged_table, tel1=1, tel2=3)
243
+ assert len(filtered) == 2
244
+
245
+ @pytest.mark.verifies_usecase("UC-120-2.3")
246
+ def test_event_selection_with_invalid_filter(self, test_calibrator):
247
+ test_calibrator.setup()
248
+ test_calibrator.event_filters = {
249
+ "non_existent_filter": 4,
250
+ }
251
+ merged_table = Table({"obs_id": [1, 2, 3, 4], "event_id": [100, 200, 300, 400]})
252
+
253
+ with pytest.raises(
254
+ ValueError,
255
+ match="Filter non_existent_filter is not implemented or not recognized.",
256
+ ):
257
+ _ = test_calibrator.event_selection(merged_table, tel1=1, tel2=2)
258
+
259
+ @pytest.mark.verifies_usecase("UC-120-2.3")
260
+ def test_system_cross_calibrator(self, test_calibrator, caplog):
261
+ # Small scale integration test
262
+ caplog.set_level(logging.ERROR, logger="calibpipe.application")
263
+ updated_cc_config = {
264
+ "CalculateCrossCalibration": {
265
+ "event_filters": {
266
+ "min_gammaness": 0.5,
267
+ },
268
+ "RelativeThroughputFitter": {
269
+ "throughput_normalization": [
270
+ ["type", "LST*", 1.0],
271
+ ["type", "MST*", 1.0],
272
+ ["type", "SST*", 1.0],
273
+ ],
274
+ "reference_telescopes": [
275
+ ["type", "LST*", 1],
276
+ ["type", "MST*", 5],
277
+ ["type", "SST*", 37],
278
+ ],
279
+ },
280
+ "PairFinder": {
281
+ "max_impact_distance": [
282
+ ["type", "LST*", 125.0],
283
+ ["type", "MST*", 125.0],
284
+ ["type", "SST*", 225.0],
285
+ ],
286
+ },
287
+ },
288
+ }
289
+ test_calibrator.update_config(Config(updated_cc_config))
290
+ test_calibrator.setup()
291
+ telescope_pairs = test_calibrator.pair_finder.find_pairs()
292
+ merged_tables = test_calibrator.merge_tables(telescope_pairs)
293
+ energy_asymmetry_results = test_calibrator.calculate_energy_asymmetry(
294
+ merged_tables
295
+ )
296
+ results = {}
297
+
298
+ for subarray_name, subarray_data in energy_asymmetry_results.items():
299
+ measured_telescopes = set(
300
+ tel_id for (i, j), entry in subarray_data.items() for tel_id in (i, j)
301
+ )
302
+ fitter = RelativeThroughputFitter(
303
+ subarray=test_calibrator.subarray.select_subarray(
304
+ tel_ids=measured_telescopes, name=subarray_name
305
+ ),
306
+ parent=test_calibrator,
307
+ )
308
+ results.update(fitter.fit(subarray_name, subarray_data))
309
+
310
+ cross_type_pairs = test_calibrator.pair_finder.find_pairs(
311
+ by_tel_type=False, cross_type_only=True
312
+ )
313
+ cross_calibration_results = test_calibrator.compute_cross_type_energy_ratios(
314
+ cross_type_pairs["XTEL"], results
315
+ )
316
+ assert cross_calibration_results[("MST", "SST")][0] == pytest.approx(
317
+ 1.0119, rel=1e-2
318
+ )
319
+ assert cross_calibration_results[("MST", "SST")][1] == pytest.approx(
320
+ 0.0042591, rel=1e-2
321
+ )
322
+ # Below testing energy asymmetry
323
+ assert energy_asymmetry_results["MST"][(6, 12)][
324
+ "mean_uncertainty"
325
+ ] == pytest.approx(1.3613071073770851e-06, rel=1e-4)
326
+ assert energy_asymmetry_results["MST"][(6, 12)][
327
+ "mean_asymmetry"
328
+ ] == pytest.approx(-0.0008690296768284202, rel=1e-2)
329
+ # Below testing the fixed telescopes
330
+ assert results["SST"]["37"][0] == pytest.approx(1.0, rel=1e-4)
331
+ assert results["MST"]["5"][0] == pytest.approx(1.0, rel=1e-4)
332
+
333
+ @pytest.mark.verifies_usecase("UC-120-2.3")
334
+ def test_save_monitoring_data(self, test_calibrator, tmp_path):
335
+ # Prepare input
336
+ class SizeType(Enum):
337
+ MST = "MST"
338
+ SST = "SST"
339
+ LST = "LST"
340
+
341
+ intercalibration_results = {
342
+ SizeType.MST: {
343
+ "6": (1.23, 0.01),
344
+ "8": (0.97, 0.02),
345
+ }
346
+ }
347
+ cross_calibration_results = {
348
+ (SizeType.MST, SizeType.SST): (1.05, 0.05),
349
+ (SizeType.LST, SizeType.SST): (1.15, 0.05),
350
+ (SizeType.LST, SizeType.MST): (0.95, 0.05),
351
+ }
352
+
353
+ # Run
354
+ updated_cc_config = {
355
+ "CalculateCrossCalibration": {
356
+ "event_filters": {
357
+ "get_gamma_like_events": 0.5,
358
+ },
359
+ "output_url": "x_calib_test_dl2.h5",
360
+ "overwrite": True,
361
+ "RelativeThroughputFitter": {
362
+ "throughput_normalization": [
363
+ ["type", "LST*", 1.0],
364
+ ["type", "MST*", 1.0],
365
+ ["type", "SST*", 1.0],
366
+ ],
367
+ "reference_telescopes": [
368
+ ["type", "LST*", 1],
369
+ ["type", "MST*", 5],
370
+ ["type", "SST*", 37],
371
+ ],
372
+ },
373
+ "PairFinder": {
374
+ "max_impact_distance": [
375
+ ["type", "LST*", 125.0],
376
+ ["type", "MST*", 125.0],
377
+ ["type", "SST*", 225.0],
378
+ ],
379
+ },
380
+ },
381
+ }
382
+ test_calibrator.update_config(Config(updated_cc_config))
383
+ test_calibrator.setup()
384
+ test_calibrator.save_monitoring_data(
385
+ intercalibration_results, cross_calibration_results
386
+ )
387
+
388
+ # Validate inter calibration table
389
+ inter_table = read_table(
390
+ test_calibrator.output_url, "/dl2/monitoring/inter_calibration"
391
+ )
392
+ assert len(inter_table) == 2
393
+ assert "tel_id" in inter_table.colnames
394
+ assert "value" in inter_table.colnames
395
+ assert "error" in inter_table.colnames
396
+
397
+ # Validate cross calibration table
398
+ cross_table = read_table(
399
+ test_calibrator.output_url, "/dl2/monitoring/cross_calibration"
400
+ )
401
+ assert len(cross_table) == 3
402
+ assert cross_table["ratio"][0] == pytest.approx(1.05)
403
+ assert cross_table["error"][0] == pytest.approx(0.05)
404
+ assert cross_table["ratio"][1] == pytest.approx(1.15)
405
+ assert cross_table["ratio"][2] == pytest.approx(0.95)
406
+
407
+ @pytest.mark.verifies_usecase("UC-120-2.3")
408
+ def test_get_equidistant_events(self, test_calibrator):
409
+ test_calibrator.setup()
410
+ set_of_events = test_calibrator._apply_max_distance_asymmetry(42, 143, 0.05)
411
+ assert (4991, 1181519) in set_of_events
412
+ assert (4991, 2196419) not in set_of_events