gammasimtools 0.25.0__py3-none-any.whl → 0.26.0__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 (125) hide show
  1. {gammasimtools-0.25.0.dist-info → gammasimtools-0.26.0.dist-info}/METADATA +2 -1
  2. {gammasimtools-0.25.0.dist-info → gammasimtools-0.26.0.dist-info}/RECORD +122 -121
  3. {gammasimtools-0.25.0.dist-info → gammasimtools-0.26.0.dist-info}/entry_points.txt +2 -1
  4. {gammasimtools-0.25.0.dist-info → gammasimtools-0.26.0.dist-info}/licenses/LICENSE +1 -1
  5. simtools/_version.py +2 -2
  6. simtools/application_control.py +35 -7
  7. simtools/applications/calculate_incident_angles.py +0 -2
  8. simtools/applications/convert_geo_coordinates_of_array_elements.py +1 -2
  9. simtools/applications/db_add_file_to_db.py +1 -1
  10. simtools/applications/db_add_simulation_model_from_repository_to_db.py +1 -1
  11. simtools/applications/db_add_value_from_json_to_db.py +1 -1
  12. simtools/applications/db_generate_compound_indexes.py +1 -1
  13. simtools/applications/db_get_array_layouts_from_db.py +2 -6
  14. simtools/applications/db_get_file_from_db.py +1 -1
  15. simtools/applications/db_get_parameter_from_db.py +1 -1
  16. simtools/applications/db_inspect_databases.py +1 -1
  17. simtools/applications/db_upload_model_repository.py +1 -1
  18. simtools/applications/derive_ctao_array_layouts.py +1 -2
  19. simtools/applications/derive_mirror_rnda.py +1 -3
  20. simtools/applications/derive_psf_parameters.py +0 -1
  21. simtools/applications/derive_pulse_shape_parameters.py +0 -1
  22. simtools/applications/derive_trigger_rates.py +1 -1
  23. simtools/applications/docs_produce_array_element_report.py +2 -8
  24. simtools/applications/docs_produce_calibration_reports.py +1 -3
  25. simtools/applications/docs_produce_model_parameter_reports.py +0 -2
  26. simtools/applications/docs_produce_simulation_configuration_report.py +1 -3
  27. simtools/applications/generate_array_config.py +0 -1
  28. simtools/applications/generate_corsika_histograms.py +48 -235
  29. simtools/applications/generate_regular_arrays.py +5 -35
  30. simtools/applications/generate_simtel_event_data.py +2 -2
  31. simtools/applications/maintain_simulation_model_add_production.py +2 -2
  32. simtools/applications/maintain_simulation_model_write_array_element_positions.py +87 -0
  33. simtools/applications/plot_array_layout.py +5 -111
  34. simtools/applications/plot_simulated_event_distributions.py +57 -0
  35. simtools/applications/plot_tabular_data.py +0 -1
  36. simtools/applications/plot_tabular_data_for_model_parameter.py +1 -6
  37. simtools/applications/production_derive_corsika_limits.py +1 -1
  38. simtools/applications/production_generate_grid.py +0 -1
  39. simtools/applications/run_application.py +1 -1
  40. simtools/applications/simulate_flasher.py +0 -2
  41. simtools/applications/simulate_illuminator.py +0 -1
  42. simtools/applications/simulate_pedestals.py +1 -5
  43. simtools/applications/simulate_prod.py +1 -5
  44. simtools/applications/simulate_prod_htcondor_generator.py +1 -1
  45. simtools/applications/submit_array_layouts.py +2 -4
  46. simtools/applications/submit_model_parameter_from_external.py +1 -3
  47. simtools/applications/validate_camera_efficiency.py +0 -1
  48. simtools/applications/validate_camera_fov.py +0 -1
  49. simtools/applications/validate_cumulative_psf.py +0 -2
  50. simtools/applications/validate_optics.py +0 -13
  51. simtools/camera/camera_efficiency.py +1 -6
  52. simtools/camera/single_photon_electron_spectrum.py +2 -1
  53. simtools/configuration/commandline_parser.py +35 -2
  54. simtools/configuration/configurator.py +6 -11
  55. simtools/corsika/corsika_config.py +16 -21
  56. simtools/corsika/corsika_histograms.py +411 -1735
  57. simtools/corsika/primary_particle.py +1 -1
  58. simtools/data_model/metadata_collector.py +5 -2
  59. simtools/data_model/metadata_model.py +0 -4
  60. simtools/data_model/model_data_writer.py +13 -15
  61. simtools/data_model/validate_data.py +1 -3
  62. simtools/db/db_handler.py +19 -8
  63. simtools/dependencies.py +81 -38
  64. simtools/io/ascii_handler.py +4 -2
  65. simtools/io/table_handler.py +1 -1
  66. simtools/layout/array_layout.py +4 -12
  67. simtools/layout/array_layout_utils.py +226 -57
  68. simtools/model/array_model.py +1 -13
  69. simtools/model/calibration_model.py +0 -4
  70. simtools/model/legacy_model_parameter.py +134 -0
  71. simtools/model/model_parameter.py +24 -13
  72. simtools/model/model_utils.py +1 -6
  73. simtools/model/site_model.py +0 -4
  74. simtools/model/telescope_model.py +6 -11
  75. simtools/production_configuration/derive_corsika_limits.py +6 -11
  76. simtools/production_configuration/interpolation_handler.py +16 -16
  77. simtools/ray_tracing/incident_angles.py +5 -11
  78. simtools/ray_tracing/mirror_panel_psf.py +3 -7
  79. simtools/ray_tracing/psf_analysis.py +18 -19
  80. simtools/ray_tracing/psf_parameter_optimisation.py +0 -1
  81. simtools/ray_tracing/ray_tracing.py +6 -15
  82. simtools/reporting/docs_auto_report_generator.py +8 -13
  83. simtools/reporting/docs_read_parameters.py +2 -8
  84. simtools/runners/corsika_runner.py +5 -9
  85. simtools/runners/corsika_simtel_runner.py +3 -8
  86. simtools/runners/simtel_runner.py +0 -5
  87. simtools/runners/simtools_runner.py +2 -4
  88. simtools/settings.py +154 -0
  89. simtools/{io/eventio_handler.py → sim_events/file_info.py} +3 -3
  90. simtools/{simtel/simtel_io_event_histograms.py → sim_events/histograms.py} +25 -15
  91. simtools/{simtel/simtel_io_event_reader.py → sim_events/reader.py} +20 -17
  92. simtools/{simtel/simtel_io_event_writer.py → sim_events/writer.py} +84 -25
  93. simtools/simtel/pulse_shapes.py +7 -2
  94. simtools/simtel/simtel_config_writer.py +79 -36
  95. simtools/simtel/simtel_table_reader.py +6 -4
  96. simtools/simtel/simulator_array.py +4 -11
  97. simtools/simtel/simulator_camera_efficiency.py +4 -6
  98. simtools/simtel/simulator_light_emission.py +69 -24
  99. simtools/simtel/simulator_ray_tracing.py +4 -10
  100. simtools/simulator.py +7 -14
  101. simtools/telescope_trigger_rates.py +3 -4
  102. simtools/testing/assertions.py +84 -33
  103. simtools/testing/configuration.py +1 -2
  104. simtools/testing/helpers.py +2 -3
  105. simtools/testing/log_inspector.py +1 -0
  106. simtools/testing/sim_telarray_metadata.py +1 -1
  107. simtools/testing/validate_output.py +34 -23
  108. simtools/utils/general.py +37 -0
  109. simtools/utils/geometry.py +0 -77
  110. simtools/utils/names.py +5 -5
  111. simtools/visualization/legend_handlers.py +7 -6
  112. simtools/visualization/plot_array_layout.py +91 -16
  113. simtools/visualization/plot_corsika_histograms.py +143 -605
  114. simtools/visualization/plot_mirrors.py +1 -4
  115. simtools/visualization/plot_pixels.py +2 -4
  116. simtools/visualization/plot_psf.py +0 -1
  117. simtools/visualization/plot_simtel_event_histograms.py +4 -4
  118. simtools/visualization/plot_simtel_events.py +6 -11
  119. simtools/visualization/plot_tables.py +8 -19
  120. simtools/visualization/visualize.py +22 -2
  121. simtools/applications/db_development_tools/write_array_elements_positions_to_repository.py +0 -160
  122. simtools/applications/print_version.py +0 -53
  123. simtools/io/hdf5_handler.py +0 -139
  124. {gammasimtools-0.25.0.dist-info → gammasimtools-0.26.0.dist-info}/WHEEL +0 -0
  125. {gammasimtools-0.25.0.dist-info → gammasimtools-0.26.0.dist-info}/top_level.txt +0 -0
@@ -13,26 +13,36 @@ from simtools.testing import assertions
13
13
 
14
14
  _logger = logging.getLogger(__name__)
15
15
 
16
+
17
+ def _versions_match(from_command_line, from_config_file):
18
+ """Return True if validations should run for the given versions.
19
+
20
+ Behavior:
21
+ - If no version is provided from the command line, run validations.
22
+ - If a filter is provided from the command line, run only when it matches
23
+ the version(s) from the config file.
24
+ """
25
+ if from_command_line is None:
26
+ return True
27
+
28
+ # Normalize to collections for comparison
29
+ cmd_versions = from_command_line if isinstance(from_command_line, list) else [from_command_line]
30
+ cfg_versions = from_config_file if isinstance(from_config_file, list) else [from_config_file]
31
+
32
+ # Consider a match if any overlap exists
33
+ return any(cv in cmd_versions for cv in cfg_versions)
34
+
35
+
16
36
  # Keys to ignore when comparing sim_telarray configuration files
17
37
  # (e.g., version numbers, system dependent parameters, CORSIKA options)
18
38
  cfg_ignore_keys = [
19
39
  "config_release",
20
40
  "Label",
21
- "simtools_version",
22
- "simtools_model_production_version",
23
- "simtools_build_opt",
24
- "simtools_extra_def",
25
- "simtools_hadronic_model",
26
- "simtools_avx_flag",
27
- "simtools_corsika_version",
28
- "simtools_corsika_opt_patch_version",
29
- "simtools_bernlohr_version",
41
+ "simtools_", # ignore all simtools_ keys - version/build info dependence
30
42
  ]
31
43
 
32
44
 
33
- def validate_application_output(
34
- config, from_command_line=None, from_config_file=None, db_config=None
35
- ):
45
+ def validate_application_output(config, from_command_line=None, from_config_file=None):
36
46
  """
37
47
  Validate application output against expected output.
38
48
 
@@ -60,8 +70,8 @@ def validate_application_output(
60
70
  f"from config file: {from_config_file}"
61
71
  )
62
72
 
63
- if from_command_line == from_config_file:
64
- _validate_output_files(config, integration_test, db_config)
73
+ if _versions_match(from_command_line, from_config_file):
74
+ _validate_output_files(config, integration_test)
65
75
 
66
76
  if "file_type" in integration_test:
67
77
  assert assertions.assert_file_type(
@@ -73,7 +83,7 @@ def validate_application_output(
73
83
  _test_simtel_cfg_files(config, integration_test, from_command_line, from_config_file)
74
84
 
75
85
 
76
- def _validate_output_files(config, integration_test, db_config):
86
+ def _validate_output_files(config, integration_test):
77
87
  """Validate output files."""
78
88
  if "reference_output_file" in integration_test:
79
89
  _validate_reference_output_file(config, integration_test)
@@ -85,11 +95,7 @@ def _validate_output_files(config, integration_test, db_config):
85
95
  [{"path_descriptor": "output_path", "file": integration_test["output_file"]}],
86
96
  )
87
97
  if "model_parameter_validation" in integration_test:
88
- _validate_model_parameter_json_file(
89
- config,
90
- integration_test["model_parameter_validation"],
91
- db_config,
92
- )
98
+ _validate_model_parameter_json_file(config, integration_test["model_parameter_validation"])
93
99
 
94
100
 
95
101
  def _test_simtel_cfg_files(config, integration_test, from_command_line, from_config_file):
@@ -135,15 +141,20 @@ def _validate_output_path_and_file(config, integration_file_tests):
135
141
  try:
136
142
  assert output_file_path.exists()
137
143
  except AssertionError as exc:
138
- raise AssertionError(f"Output file {output_file_path} does not exist. ") from exc
144
+ raise AssertionError(
145
+ f"Output file {output_file_path} does not exist. "
146
+ f"Directory contents: {list(output_file_path.parent.iterdir())}"
147
+ ) from exc
139
148
 
140
149
  if output_file_path.name.endswith(".simtel.zst"):
141
150
  assert assertions.check_output_from_sim_telarray(output_file_path, file_test)
142
151
  elif output_file_path.name.endswith(".log_hist.tar.gz"):
143
152
  assert assertions.check_simulation_logs(output_file_path, file_test)
153
+ elif output_file_path.suffix == ".log":
154
+ assert assertions.check_plain_log(output_file_path, file_test)
144
155
 
145
156
 
146
- def _validate_model_parameter_json_file(config, model_parameter_validation, db_config):
157
+ def _validate_model_parameter_json_file(config, model_parameter_validation):
147
158
  """
148
159
  Validate model parameter json file and compare it with a reference parameter from the database.
149
160
 
@@ -158,7 +169,7 @@ def _validate_model_parameter_json_file(config, model_parameter_validation, db_c
158
169
 
159
170
  """
160
171
  _logger.info(f"Checking model parameter json file: {model_parameter_validation}")
161
- db = db_handler.DatabaseHandler(db_config=db_config)
172
+ db = db_handler.DatabaseHandler()
162
173
 
163
174
  reference_parameter_name = model_parameter_validation.get("reference_parameter_name")
164
175
 
simtools/utils/general.py CHANGED
@@ -11,6 +11,7 @@ import urllib.request
11
11
  from pathlib import Path
12
12
  from urllib.parse import urlparse
13
13
 
14
+ import dotenv
14
15
  import numpy as np
15
16
 
16
17
  _logger = logging.getLogger(__name__)
@@ -850,3 +851,39 @@ def get_list_of_files_from_command_line(file_names, suffix_list):
850
851
  def now_date_time_in_isoformat():
851
852
  """Return date and time in isoformat and second accuracy."""
852
853
  return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
854
+
855
+
856
+ def load_environment_variables(env_file=".env", env_list=None):
857
+ """
858
+ Load environment variables (from a .env file or directly from the environment).
859
+
860
+ Allow to read a specific list of variables or all variables from the .env file.
861
+
862
+ Parameters
863
+ ----------
864
+ env_file: str
865
+ Path to the .env file.
866
+ env_list: list, optional
867
+ List of environment variables to be read. If None, all variables are read.
868
+
869
+ Returns
870
+ -------
871
+ dict
872
+ Dictionary mapping environment variable names (lowercase, without the
873
+ ``SIMTOOLS_`` prefix) to their cleaned string values.
874
+ """
875
+ dotenv.load_dotenv(env_file or None)
876
+ keys = (
877
+ list(dotenv.dotenv_values(env_file).keys())
878
+ if env_list is None
879
+ else [f"SIMTOOLS_{s.upper()}" for s in env_list]
880
+ )
881
+
882
+ env_values = {}
883
+ for key in keys:
884
+ env_value = os.environ.get(key)
885
+ if env_value is None:
886
+ continue
887
+ cleaned_value = env_value.split("#")[0].strip().replace('"', "").replace("'", "")
888
+ env_values[key.removeprefix("SIMTOOLS_").lower()] = cleaned_value
889
+ return env_values
@@ -1,88 +1,11 @@
1
1
  """A collection of functions related to geometrical transformations."""
2
2
 
3
- import logging
4
3
  import math
5
4
 
6
5
  import astropy.units as u
7
6
  import numpy as np
8
7
  from astropy.units import UnitsError
9
8
 
10
- _logger = logging.getLogger(__name__)
11
-
12
-
13
- def convert_2d_to_radial_distr(hist_2d, xaxis, yaxis, bins=50, max_dist=1000):
14
- """
15
- Convert a 2d histogram of positions, e.g. photon positions on the ground, to a 1D distribution.
16
-
17
- Parameters
18
- ----------
19
- hist_2d: numpy.ndarray
20
- The histogram counts.
21
- xaxis: numpy.array
22
- The values of the x axis (histogram bin edges) on the ground.
23
- yaxis: numpy.array
24
- The values of the y axis (histogram bin edges) on the ground.
25
- bins: float
26
- Number of bins in distance.
27
- max_dist: float
28
- Maximum distance to consider in the 1D histogram, usually in meters.
29
-
30
- Returns
31
- -------
32
- np.array
33
- The values of the 1D histogram with size = int(max_dist/bin_size).
34
- np.array
35
- The bin edges of the 1D histogram with size = int(max_dist/bin_size) + 1.
36
-
37
- """
38
- # Check if the histogram will make sense
39
- bins_step = 2 * max_dist / bins # in the 2d array, the positive and negative direction count.
40
- for axis in [xaxis, yaxis]:
41
- if (bins_step < np.diff(axis)).any():
42
- msg = (
43
- f"The histogram with number of bins {bins} and maximum distance of {max_dist} "
44
- f"resulted in a bin size smaller than the original array. Please adjust those "
45
- f"parameters to increase the bin size and avoid nan in the histogram values."
46
- )
47
- _logger.warning(msg)
48
- break
49
-
50
- grid_2d_x, grid_2d_y = np.meshgrid(xaxis[:-1], yaxis[:-1]) # [:-1], since xaxis and yaxis are
51
- # the hist bin_edges (n + 1).
52
- # radial_distance_map maps the distance to the center from each element in a square matrix.
53
- radial_distance_map = np.sqrt(grid_2d_x**2 + grid_2d_y**2)
54
- # The sorting and unravel_index give us the two indices for the position of the sorted element
55
- # in the original 2d matrix
56
- sorted_indices = np.unravel_index(
57
- np.argsort(radial_distance_map, axis=None), np.shape(radial_distance_map)
58
- )
59
- x_indices_sorted, y_indices_sorted = sorted_indices[0], sorted_indices[1]
60
-
61
- # We construct a 1D array with the histogram counts sorted according to the distance to the
62
- # center.
63
- hist_sorted = np.array(
64
- [hist_2d[i_x, i_y] for i_x, i_y in zip(x_indices_sorted, y_indices_sorted)]
65
- )
66
- distance_sorted = np.sort(radial_distance_map, axis=None)
67
-
68
- # For larger distances, we have more elements in a slice 'dr' in radius, hence, we need to
69
- # account for it using weights below.
70
-
71
- weights, radial_bin_edges = np.histogram(distance_sorted, bins=bins, range=(0, max_dist))
72
- histogram_1d = np.empty_like(weights, dtype=float)
73
-
74
- for i_radial, _ in enumerate(radial_bin_edges[:-1]):
75
- # Here we sum all the events within a radial interval 'dr' and then divide by the number of
76
- # bins that fit this interval.
77
- indices_to_sum = (distance_sorted >= radial_bin_edges[i_radial]) * (
78
- distance_sorted < radial_bin_edges[i_radial + 1]
79
- )
80
- if weights[i_radial] != 0:
81
- histogram_1d[i_radial] = np.sum(hist_sorted[indices_to_sum]) / weights[i_radial]
82
- else:
83
- histogram_1d[i_radial] = 0
84
- return histogram_1d, radial_bin_edges
85
-
86
9
 
87
10
  @u.quantity_input(rotation_angle_phi=u.rad, rotation_angle_theta=u.rad)
88
11
  def rotate(x, y, rotation_around_z_axis, rotation_around_y_axis=0):
simtools/utils/names.py CHANGED
@@ -189,13 +189,13 @@ def model_parameters(class_key_list=None):
189
189
  dict
190
190
  Model parameters definitions.
191
191
  """
192
- _parameters = {}
193
192
  if class_key_list is None:
194
193
  return _load_model_parameters()
195
- for key, value in _load_model_parameters().items():
196
- if value.get("instrument", {}).get("class", "") in class_key_list:
197
- _parameters[key] = value
198
- return _parameters
194
+ return {
195
+ key: value
196
+ for key, value in _load_model_parameters().items()
197
+ if value.get("instrument", {}).get("class", "") in class_key_list
198
+ }
199
199
 
200
200
 
201
201
  def site_parameters():
@@ -1,14 +1,13 @@
1
1
  """Helper functions for legend handlers used for plotting."""
2
2
 
3
+ # pylint: disable=too-few-public-methods
4
+
3
5
  import matplotlib.colors as mcolors
4
6
  import matplotlib.patches as mpatches
5
7
  import numpy as np
6
8
 
7
- """
8
- Define properties of different telescope types for visualization purposes.
9
-
10
- Radii are relative to a reference radius (REFERENCE_RADIUS).
11
- """
9
+ # Define properties of different telescope types for visualization purposes.
10
+ # Radii are relative to a reference radius (REFERENCE_RADIUS).
12
11
  TELESCOPE_CONFIG = {
13
12
  "LST": {"color": "darkorange", "radius": 12.5, "shape": "circle", "filled": False},
14
13
  "MST": {"color": "dodgerblue", "radius": 9.15, "shape": "circle", "filled": False},
@@ -51,7 +50,7 @@ def get_telescope_config(telescope_type):
51
50
  config = TELESCOPE_CONFIG.get(telescope_type)
52
51
  if not config and len(telescope_type) >= 3:
53
52
  config = TELESCOPE_CONFIG.get(telescope_type[:3])
54
- return config
53
+ return config.copy() if config else None
55
54
 
56
55
 
57
56
  def calculate_center(handlebox, width_factor=3, height_factor=3):
@@ -272,6 +271,8 @@ class BaseLegendHandler:
272
271
  x0, y0 = calculate_center(handlebox)
273
272
  radius = handlebox.height
274
273
  patch = self._create_hexagon(handlebox, x0, y0, radius)
274
+ else:
275
+ raise ValueError(f"Unknown shape: {shape}")
275
276
 
276
277
  handlebox.add_artist(patch)
277
278
  return patch
@@ -5,15 +5,18 @@ from collections import Counter
5
5
  from typing import NamedTuple
6
6
 
7
7
  import astropy.units as u
8
+ import matplotlib as mpl
8
9
  import matplotlib.patches as mpatches
9
10
  import matplotlib.pyplot as plt
10
11
  import numpy as np
12
+ from adjustText import adjust_text
11
13
  from astropy.table import Column
12
14
  from matplotlib.collections import PatchCollection
13
15
 
14
16
  from simtools.utils import geometry as transf
15
17
  from simtools.utils import names
16
18
  from simtools.visualization import legend_handlers as leg_h
19
+ from simtools.visualization import visualize
17
20
 
18
21
 
19
22
  class PlotBounds(NamedTuple):
@@ -31,6 +34,61 @@ class PlotBounds(NamedTuple):
31
34
  y_lim: tuple[float, float]
32
35
 
33
36
 
37
+ def plot_array_layouts(args_dict, output_path, layouts, background_layout=None):
38
+ """
39
+ Plot multiple array layouts.
40
+
41
+ Parameters
42
+ ----------
43
+ args_dict : dict
44
+ Application arguments.
45
+ output_path : Path
46
+ Output path for figures.
47
+ layouts : dict
48
+ Dictionary of layout name to telescope table.
49
+ background_layout : Table or None
50
+ Optional background telescope table.
51
+
52
+ Returns
53
+ -------
54
+ figs : dict
55
+ Dictionary of layout name to matplotlib figure object.
56
+
57
+ """
58
+ mpl.use("Agg")
59
+ for layout in layouts:
60
+ fig_out = plot_array_layout(
61
+ telescopes=layout["array_elements"],
62
+ show_tel_label=args_dict["show_labels"],
63
+ axes_range=args_dict["axes_range"],
64
+ marker_scaling=args_dict["marker_scaling"],
65
+ background_telescopes=background_layout,
66
+ grayed_out_elements=args_dict["grayed_out_array_elements"],
67
+ highlighted_elements=args_dict["highlighted_array_elements"],
68
+ legend_location=args_dict["legend_location"],
69
+ bounds_mode=args_dict["bounds"],
70
+ padding=args_dict["padding"],
71
+ x_lim=tuple(args_dict["x_lim"]) if args_dict["x_lim"] else None,
72
+ y_lim=tuple(args_dict["y_lim"]) if args_dict["y_lim"] else None,
73
+ )
74
+ site_string = ""
75
+ if layout.get("site") is not None:
76
+ site_string = f"_{layout['site']}"
77
+ elif args_dict["site"] is not None:
78
+ site_string = f"_{args_dict['site']}"
79
+ coordinate_system_string = (
80
+ f"_{args_dict['coordinate_system']}"
81
+ if args_dict["coordinate_system"] not in layout["name"]
82
+ else ""
83
+ )
84
+ plot_file_name = args_dict["figure_name"] or (
85
+ f"array_layout_{layout['name']}{site_string}{coordinate_system_string}"
86
+ )
87
+
88
+ visualize.save_figure(fig_out, output_path / plot_file_name, dpi=400)
89
+ plt.close()
90
+
91
+
34
92
  def plot_array_layout(
35
93
  telescopes,
36
94
  show_tel_label=False,
@@ -89,7 +147,7 @@ def plot_array_layout(
89
147
  filter_x = x_lim
90
148
  filter_y = y_lim
91
149
 
92
- patches, plot_range, highlighted_patches, bounds = get_patches(
150
+ patches, plot_range, highlighted_patches, bounds, text_objects = get_patches(
93
151
  ax,
94
152
  telescopes,
95
153
  show_tel_label,
@@ -122,6 +180,17 @@ def plot_array_layout(
122
180
 
123
181
  finalize_plot(ax, patches, "Easting [m]", "Northing [m]", x_lim, y_lim, highlighted_patches)
124
182
 
183
+ if text_objects:
184
+ adjust_text(
185
+ text_objects,
186
+ ax=ax,
187
+ arrowprops={"arrowstyle": "->", "color": "grey", "alpha": 0.8, "lw": 0.8, "ls": "--"},
188
+ expand=(2.0, 2.0),
189
+ prevent_crossings=True,
190
+ min_arrow_len=8,
191
+ ensure_inside_axes=True,
192
+ )
193
+
125
194
  return fig
126
195
 
127
196
 
@@ -178,7 +247,7 @@ def _get_patches_for_background_telescopes(
178
247
  if background_telescopes is None:
179
248
  return plot_range, bounds
180
249
 
181
- bg_patches, bg_range, _, bg_bounds = get_patches(
250
+ bg_patches, bg_range, _, bg_bounds, _ = get_patches(
182
251
  ax,
183
252
  background_telescopes,
184
253
  False,
@@ -268,6 +337,8 @@ def get_patches(
268
337
  List of highlighted telescope patches.
269
338
  bounds : PlotBounds
270
339
  Min/max for x and y in meters.
340
+ text_objects : list
341
+ List of text objects for labels.
271
342
  """
272
343
  pos_x, pos_y = get_positions(telescopes)
273
344
  tel_table, pos_x, pos_y = _apply_limits_filter(
@@ -277,7 +348,7 @@ def get_patches(
277
348
  tel_table["pos_x_rotated"] = Column(pos_x)
278
349
  tel_table["pos_y_rotated"] = Column(pos_y)
279
350
 
280
- patches, radii, highlighted_patches = create_patches(
351
+ patches, radii, highlighted_patches, text_objects = create_patches(
281
352
  tel_table, marker_scaling, show_tel_label, ax, grayed_out_elements, highlighted_elements
282
353
  )
283
354
 
@@ -290,8 +361,8 @@ def get_patches(
290
361
  if len(pos_x) == 0:
291
362
  bounds = PlotBounds(x_lim=(0.0, 0.0), y_lim=(0.0, 0.0))
292
363
  if axes_range:
293
- return patches, axes_range, highlighted_patches, bounds
294
- return patches, 0.0, highlighted_patches, bounds
364
+ return patches, axes_range, highlighted_patches, bounds, text_objects
365
+ return patches, 0.0, highlighted_patches, bounds, text_objects
295
366
 
296
367
  x_min = float(np.nanmin(pos_x).to_value(u.m)) - r
297
368
  x_max = float(np.nanmax(pos_x).to_value(u.m)) + r
@@ -300,13 +371,13 @@ def get_patches(
300
371
  bounds = PlotBounds(x_lim=(x_min, x_max), y_lim=(y_min, y_max))
301
372
 
302
373
  if axes_range:
303
- return patches, axes_range, highlighted_patches, bounds
374
+ return patches, axes_range, highlighted_patches, bounds, text_objects
304
375
 
305
376
  max_x = max(abs(x_min), abs(x_max))
306
377
  max_y = max(abs(y_min), abs(y_max))
307
378
  updated_axes_range = max(max_x, max_y) * 1.1
308
379
 
309
- return patches, updated_axes_range, highlighted_patches, bounds
380
+ return patches, updated_axes_range, highlighted_patches, bounds, text_objects
310
381
 
311
382
 
312
383
  @u.quantity_input(x=u.m, y=u.m, radius=u.m)
@@ -417,8 +488,10 @@ def create_patches(
417
488
  Telescope radii.
418
489
  highlighted_patches : list
419
490
  List of highlighted telescope patches.
491
+ text_objects : list
492
+ List of text objects for labels.
420
493
  """
421
- patches, radii, highlighted_patches = [], [], []
494
+ patches, radii, highlighted_patches, text_objects = [], [], [], []
422
495
  fontsize, scale_factor = (4, 2) if len(telescopes) > 30 else (8, 1)
423
496
 
424
497
  grayed_out_set = set(grayed_out_elements) if grayed_out_elements else set()
@@ -457,16 +530,18 @@ def create_patches(
457
530
  highlighted_patches.append(highlight_patch)
458
531
 
459
532
  if show_label:
460
- ax.text(
461
- tel["pos_x_rotated"].value,
462
- tel["pos_y_rotated"].value + scale_factor * radius.value,
463
- name,
464
- ha="center",
465
- va="bottom",
466
- fontsize=fontsize * 0.8,
533
+ text_objects.append(
534
+ ax.text(
535
+ tel["pos_x_rotated"].value,
536
+ tel["pos_y_rotated"].value + scale_factor * radius.value,
537
+ name,
538
+ ha="center",
539
+ va="center",
540
+ fontsize=fontsize * 0.8,
541
+ )
467
542
  )
468
543
 
469
- return patches, radii, highlighted_patches
544
+ return patches, radii, highlighted_patches, text_objects
470
545
 
471
546
 
472
547
  def get_telescope_name(tel):