gammasimtools 0.16.0__py3-none-any.whl → 0.18.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 (85) hide show
  1. {gammasimtools-0.16.0.dist-info → gammasimtools-0.18.0.dist-info}/METADATA +5 -2
  2. {gammasimtools-0.16.0.dist-info → gammasimtools-0.18.0.dist-info}/RECORD +82 -74
  3. {gammasimtools-0.16.0.dist-info → gammasimtools-0.18.0.dist-info}/WHEEL +1 -1
  4. {gammasimtools-0.16.0.dist-info → gammasimtools-0.18.0.dist-info}/entry_points.txt +4 -1
  5. simtools/_version.py +2 -2
  6. simtools/applications/db_add_simulation_model_from_repository_to_db.py +10 -1
  7. simtools/applications/derive_ctao_array_layouts.py +5 -5
  8. simtools/applications/derive_mirror_rnda.py +1 -1
  9. simtools/applications/generate_simtel_event_data.py +128 -46
  10. simtools/applications/merge_tables.py +102 -0
  11. simtools/applications/plot_array_layout.py +145 -258
  12. simtools/applications/plot_tabular_data.py +12 -1
  13. simtools/applications/plot_tabular_data_for_model_parameter.py +103 -0
  14. simtools/applications/production_derive_corsika_limits.py +78 -225
  15. simtools/applications/production_derive_statistics.py +77 -43
  16. simtools/applications/simulate_light_emission.py +1 -0
  17. simtools/applications/simulate_prod.py +30 -18
  18. simtools/applications/simulate_prod_htcondor_generator.py +0 -1
  19. simtools/applications/submit_array_layouts.py +93 -0
  20. simtools/applications/verify_simulation_model_production_tables.py +52 -0
  21. simtools/camera/camera_efficiency.py +3 -3
  22. simtools/configuration/commandline_parser.py +30 -35
  23. simtools/configuration/configurator.py +0 -4
  24. simtools/constants.py +2 -0
  25. simtools/corsika/corsika_config.py +17 -12
  26. simtools/corsika/primary_particle.py +46 -13
  27. simtools/data_model/metadata_collector.py +7 -3
  28. simtools/data_model/schema.py +15 -1
  29. simtools/db/db_handler.py +16 -11
  30. simtools/db/db_model_upload.py +2 -2
  31. simtools/io_operations/io_handler.py +2 -2
  32. simtools/io_operations/io_table_handler.py +345 -0
  33. simtools/job_execution/htcondor_script_generator.py +2 -2
  34. simtools/job_execution/job_manager.py +7 -121
  35. simtools/layout/array_layout_utils.py +389 -0
  36. simtools/model/array_model.py +10 -1
  37. simtools/model/model_repository.py +134 -0
  38. simtools/production_configuration/{calculate_statistical_errors_grid_point.py → calculate_statistical_uncertainties_grid_point.py} +101 -112
  39. simtools/production_configuration/derive_corsika_limits.py +239 -111
  40. simtools/production_configuration/derive_corsika_limits_grid.py +232 -0
  41. simtools/production_configuration/derive_production_statistics.py +57 -26
  42. simtools/production_configuration/derive_production_statistics_handler.py +70 -37
  43. simtools/production_configuration/interpolation_handler.py +296 -94
  44. simtools/ray_tracing/ray_tracing.py +7 -6
  45. simtools/reporting/docs_read_parameters.py +104 -62
  46. simtools/resources/array-element-ids.json +126 -0
  47. simtools/runners/corsika_simtel_runner.py +4 -1
  48. simtools/runners/runner_services.py +5 -4
  49. simtools/schemas/model_parameter_and_data_schema.metaschema.yml +5 -1
  50. simtools/schemas/model_parameters/atmospheric_profile.schema.yml +41 -0
  51. simtools/schemas/model_parameters/atmospheric_transmission.schema.yml +43 -0
  52. simtools/schemas/model_parameters/camera_filter.schema.yml +10 -0
  53. simtools/schemas/model_parameters/camera_filter_incidence_angle.schema.yml +10 -0
  54. simtools/schemas/model_parameters/discriminator_pulse_shape.schema.yml +31 -0
  55. simtools/schemas/model_parameters/dsum_threshold.schema.yml +41 -0
  56. simtools/schemas/model_parameters/fadc_pulse_shape.schema.yml +12 -0
  57. simtools/schemas/model_parameters/lightguide_efficiency_vs_incidence_angle.schema.yml +10 -0
  58. simtools/schemas/model_parameters/mirror_reflectivity.schema.yml +10 -0
  59. simtools/schemas/model_parameters/nsb_reference_spectrum.schema.yml +12 -0
  60. simtools/schemas/model_parameters/pm_photoelectron_spectrum.schema.yml +19 -0
  61. simtools/schemas/model_parameters/quantum_efficiency.schema.yml +10 -0
  62. simtools/schemas/plot_configuration.metaschema.yml +46 -57
  63. simtools/schemas/production_configuration_metrics.schema.yml +2 -2
  64. simtools/simtel/simtel_config_writer.py +34 -14
  65. simtools/simtel/simtel_io_event_reader.py +301 -194
  66. simtools/simtel/simtel_io_event_writer.py +237 -221
  67. simtools/simtel/simtel_io_file_info.py +9 -4
  68. simtools/simtel/simtel_io_metadata.py +119 -8
  69. simtools/simtel/simulator_array.py +2 -2
  70. simtools/simtel/simulator_light_emission.py +79 -34
  71. simtools/simtel/simulator_ray_tracing.py +2 -2
  72. simtools/simulator.py +101 -68
  73. simtools/testing/validate_output.py +4 -1
  74. simtools/utils/general.py +1 -3
  75. simtools/utils/names.py +76 -7
  76. simtools/visualization/plot_array_layout.py +242 -0
  77. simtools/visualization/plot_pixels.py +680 -0
  78. simtools/visualization/plot_tables.py +81 -2
  79. simtools/visualization/visualize.py +3 -219
  80. simtools/applications/production_generate_simulation_config.py +0 -152
  81. simtools/layout/ctao_array_layouts.py +0 -172
  82. simtools/production_configuration/generate_simulation_config.py +0 -158
  83. {gammasimtools-0.16.0.dist-info → gammasimtools-0.18.0.dist-info}/licenses/LICENSE +0 -0
  84. {gammasimtools-0.16.0.dist-info → gammasimtools-0.18.0.dist-info}/top_level.txt +0 -0
  85. /simtools/{schemas → resources}/array_elements.yml +0 -0
@@ -0,0 +1,680 @@
1
+ #!/usr/bin/python3
2
+ """Functions for plotting pixel layout information."""
3
+
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import astropy.units as u
8
+ import matplotlib.colors as mcolors
9
+ import matplotlib.patches as mpatches
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+ from matplotlib.collections import PatchCollection
13
+
14
+ from simtools.db import db_handler
15
+ from simtools.io_operations import io_handler
16
+ from simtools.model.model_utils import is_two_mirror_telescope
17
+ from simtools.utils import names
18
+ from simtools.visualization import legend_handlers as leg_h
19
+ from simtools.visualization import visualize
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def plot(config, output_file, db_config=None):
25
+ """
26
+ Plot pixel layout based on configuration.
27
+
28
+ Parameters
29
+ ----------
30
+ config : dict
31
+ Configuration dictionary containing:
32
+ - file_name : str, name of camera config file
33
+ - column_x : str, x-axis label
34
+ - column_y : str, y-axis label
35
+ - parameter_version: str, version of the parameter
36
+ - telescope : str, name of the telescope
37
+ output_file : str
38
+ Path where to save the plot
39
+ db_config : dict, optional
40
+ Database configuration.
41
+
42
+ Returns
43
+ -------
44
+ None
45
+ The function saves the plot to the specified output file.
46
+ """
47
+ db = db_handler.DatabaseHandler(mongo_db_config=db_config)
48
+ db.export_model_file(
49
+ parameter=config["parameter"],
50
+ site=config["site"],
51
+ array_element_name=config.get("telescope"),
52
+ parameter_version=config.get("parameter_version"),
53
+ model_version=config.get("model_version"),
54
+ export_file_as_table=False,
55
+ )
56
+ data_file_path = Path(io_handler.IOHandler().get_output_directory() / f"{config['file_name']}")
57
+ fig = plot_pixel_layout_from_file(
58
+ data_file_path,
59
+ config["telescope"],
60
+ pixels_id_to_print=80,
61
+ )
62
+ visualize.save_figure(fig, output_file)
63
+ plt.close(fig)
64
+
65
+
66
+ def plot_pixel_layout_from_file(dat_file_path, telescope_model_name, **kwargs):
67
+ """
68
+ Plot the pixel layout from a camera config file.
69
+
70
+ This function reads the pixel configuration from the specified camera config file and
71
+ generates a plot of the pixel layout for the given telescope model.
72
+
73
+ Parameters
74
+ ----------
75
+ dat_file_path : str or Path
76
+ Path to the camera config file containing pixel configuration
77
+ telescope_model_name : str
78
+ Name/model of the telescope
79
+ **kwargs
80
+ pixels_id_to_print : int
81
+ Number of pixel IDs to print in the plot
82
+ title : str
83
+ Plot title
84
+ xtitle : str
85
+ X-axis label
86
+ ytitle : str
87
+ Y-axis label
88
+
89
+ Returns
90
+ -------
91
+ matplotlib.figure.Figure
92
+ The generated figure
93
+ """
94
+ logger.info(f"Plotting pixel layout for {telescope_model_name} from {dat_file_path}")
95
+
96
+ pixel_data = _prepare_pixel_data(
97
+ dat_file_path,
98
+ telescope_model_name,
99
+ )
100
+
101
+ return _create_pixel_plot(
102
+ pixel_data,
103
+ telescope_model_name,
104
+ pixels_id_to_print=kwargs.get("pixels_id_to_print", 50),
105
+ title=kwargs.get("title"),
106
+ xtitle=kwargs.get("xtitle"),
107
+ ytitle=kwargs.get("ytitle"),
108
+ )
109
+
110
+
111
+ def _prepare_pixel_data(dat_file_path, telescope_model_name):
112
+ """Prepare pixel data from sim_telarray camera configuration file.
113
+
114
+ This function reads the pixel configuration from the specified camera config file and
115
+ prepares the data for plotting, including applying any necessary rotations.
116
+
117
+ Parameters
118
+ ----------
119
+ dat_file_path : str or Path
120
+ Path to the camera config file containing pixel configuration
121
+ telescope_model_name : str
122
+ Name/model of the telescope
123
+
124
+ Returns
125
+ -------
126
+ dict
127
+ Dictionary containing pixel data
128
+ """
129
+ config = _read_pixel_config(dat_file_path)
130
+ x_pos = np.array(config["x"])
131
+ y_pos = np.array(config["y"])
132
+
133
+ if not is_two_mirror_telescope(telescope_model_name):
134
+ y_pos = -y_pos
135
+
136
+ rotate_angle = (
137
+ config.get("rotate_angle") if config.get("rotate_angle") is not None else (0.0 * u.deg)
138
+ )
139
+
140
+ # Apply telescope-specific adjustments
141
+ if "SST" in telescope_model_name or "SCT" in telescope_model_name:
142
+ total_rotation = (90 * u.deg) - (rotate_angle)
143
+ else:
144
+ total_rotation = (-90 * u.deg) - (rotate_angle)
145
+
146
+ # Apply rotation
147
+ rot_angle = total_rotation.to(u.rad).value
148
+ x_rot = x_pos * np.cos(rot_angle) - y_pos * np.sin(rot_angle)
149
+ y_rot = y_pos * np.cos(rot_angle) + x_pos * np.sin(rot_angle)
150
+ x_pos, y_pos = x_rot, y_rot
151
+
152
+ return {
153
+ "x": x_pos,
154
+ "y": y_pos,
155
+ "pixel_ids": config["pixel_ids"],
156
+ "pixels_on": config["pixels_on"],
157
+ "pixel_shape": config["pixel_shape"],
158
+ "pixel_diameter": config["pixel_diameter"],
159
+ "pixel_spacing": config["pixel_spacing"],
160
+ "module_number": config["module_number"],
161
+ "module_gap": config["module_gap"],
162
+ "rotation": total_rotation,
163
+ }
164
+
165
+
166
+ def _create_pixel_plot(
167
+ pixel_data, telescope_model_name, pixels_id_to_print=50, title=None, xtitle=None, ytitle=None
168
+ ):
169
+ """
170
+ Create and configure the pixel layout plot.
171
+
172
+ Parameters
173
+ ----------
174
+ pixel_data : dict
175
+ Dictionary containing pixel configuration data
176
+ telescope_model_name : str
177
+ Name of telescope model
178
+ pixels_id_to_print : int, optional
179
+ Number of pixel IDs to print, default 50
180
+ title : str, optional
181
+ Plot title
182
+ xtitle : str, optional
183
+ X-axis label
184
+ ytitle : str, optional
185
+ Y-axis label
186
+
187
+ Returns
188
+ -------
189
+ matplotlib.figure.Figure
190
+ The generated figure
191
+ """
192
+ fig, ax = plt.subplots(figsize=(8, 8))
193
+
194
+ # Create patches
195
+ on_pixels, edge_pixels, off_pixels = _create_pixel_patches(
196
+ pixel_data["x"],
197
+ pixel_data["y"],
198
+ pixel_data["pixel_diameter"],
199
+ pixel_data["module_number"],
200
+ pixel_data["module_gap"],
201
+ pixel_data["pixel_spacing"],
202
+ pixel_data["pixel_shape"],
203
+ pixel_data["pixels_on"],
204
+ pixel_data["pixel_ids"],
205
+ pixels_id_to_print,
206
+ telescope_model_name,
207
+ )
208
+
209
+ # Combine all patches into a single collection
210
+ all_patches = on_pixels + edge_pixels + off_pixels
211
+ facecolors = [
212
+ "none"
213
+ if i < len(on_pixels)
214
+ else (*mcolors.to_rgb("brown"), 0.5)
215
+ if i < len(on_pixels) + len(edge_pixels)
216
+ else "black"
217
+ for i in range(len(on_pixels) + len(edge_pixels) + len(off_pixels))
218
+ ]
219
+ edgecolors = (
220
+ ["black"] * len(on_pixels)
221
+ + [(*mcolors.to_rgb("black"), 1)] * len(edge_pixels)
222
+ + ["black"] * len(off_pixels)
223
+ )
224
+ linewidths = [0.2] * len(all_patches)
225
+
226
+ # Add the combined collection
227
+ ax.add_collection(
228
+ PatchCollection(
229
+ all_patches,
230
+ facecolor=facecolors,
231
+ edgecolor=edgecolors,
232
+ linewidth=linewidths,
233
+ match_original=True,
234
+ )
235
+ )
236
+
237
+ # Configure plot with titles
238
+ _configure_plot(
239
+ ax,
240
+ pixel_data["x"],
241
+ pixel_data["y"],
242
+ rotation=pixel_data["rotation"],
243
+ title=title,
244
+ xtitle=xtitle,
245
+ ytitle=ytitle,
246
+ )
247
+ _add_legend(ax, on_pixels, off_pixels)
248
+
249
+ return fig
250
+
251
+
252
+ def _read_pixel_config(dat_file_path):
253
+ """Read pixel configuration from a camera configuration file.
254
+
255
+ This function reads the pixel configuration from the specified camera config file and
256
+ returns it as a dictionary. It parses information such as pixel positions,
257
+ module numbers, and other relevant parameters.
258
+
259
+ Parameters
260
+ ----------
261
+ dat_file_path : str or Path
262
+ Path to the camera config file containing pixel configuration
263
+
264
+ Returns
265
+ -------
266
+ dict
267
+ config containing pixel data
268
+ """
269
+ config = {
270
+ "x": [],
271
+ "y": [],
272
+ "pixel_ids": [],
273
+ "pixels_on": [],
274
+ "pixel_shape": None,
275
+ "pixel_diameter": None,
276
+ "pixel_spacing": None,
277
+ "module_gap": None,
278
+ "trigger_groups": [],
279
+ "rotate_angle": None,
280
+ "module_number": [],
281
+ }
282
+
283
+ with open(dat_file_path, encoding="utf-8") as f:
284
+ for line in f:
285
+ line = line.strip()
286
+
287
+ if not line:
288
+ continue
289
+
290
+ # Parse specific information from the file
291
+ if line.startswith("Rotate"):
292
+ # Parse rotation angle from line like "Rotate 10.893" (u.deg)
293
+ config["rotate_angle"] = float(line.split()[1].strip()) * u.deg
294
+
295
+ elif line.startswith("PixType"):
296
+ parts = line.split()
297
+ config["pixel_shape"] = int(parts[5].strip())
298
+ config["pixel_diameter"] = float(parts[6].strip())
299
+
300
+ elif "Pixel spacing is" in line:
301
+ config["pixel_spacing"] = float(line.split("spacing is")[1].strip().split()[0])
302
+
303
+ elif "Between modules is an additional gap of" in line:
304
+ config["module_gap"] = float(line.split("gap of")[1].strip().split()[0])
305
+
306
+ elif line.startswith("Pixel"):
307
+ parts = line.split()
308
+ config["x"].append(float(parts[3].strip()))
309
+ config["y"].append(float(parts[4].strip()))
310
+ config["module_number"].append(float(parts[5].strip()))
311
+ config["pixel_ids"].append(int(parts[1].strip()))
312
+ config["pixels_on"].append(int(parts[9].strip()) != 0)
313
+
314
+ config["pixel_spacing"] = (
315
+ config["pixel_diameter"] if config["pixel_spacing"] is None else config["pixel_spacing"]
316
+ )
317
+ config["module_gap"] = 0.0 if config["module_gap"] is None else config["module_gap"]
318
+
319
+ return config
320
+
321
+
322
+ def _create_patch(x, y, diameter, shape):
323
+ """Create a single matplotlib patch for a pixel.
324
+
325
+ This function creates a matplotlib patch (shape) for a single pixel based on
326
+ its position, diameter, and shape type. Supported shapes are circles, squares,
327
+ and hexagons.
328
+
329
+ Parameters
330
+ ----------
331
+ x, y : float
332
+ Center coordinates of the pixel
333
+ diameter : float
334
+ Diameter of the pixel
335
+ shape : int
336
+ Pixel shape type:
337
+ 0: circular
338
+ 1: hexagonal (flat x)
339
+ 2: square
340
+ 3: hexagonal (flat y)
341
+
342
+ Returns
343
+ -------
344
+ matplotlib.patches.Patch
345
+ The created patch object for the pixel
346
+ """
347
+ if shape == 0: # Circular
348
+ return mpatches.Circle((x, y), radius=diameter / 2)
349
+ if shape in (1, 3): # Hexagonal
350
+ return mpatches.RegularPolygon(
351
+ (x, y),
352
+ numVertices=6,
353
+ radius=diameter / np.sqrt(3),
354
+ orientation=np.deg2rad(30 if shape == 3 else 0),
355
+ )
356
+ # Square
357
+ return mpatches.Rectangle((x - diameter / 2, y - diameter / 2), width=diameter, height=diameter)
358
+
359
+
360
+ def _is_edge_pixel(
361
+ x, y, x_pos, y_pos, module_ids, pixel_spacing, module_gap, shape, current_module_id
362
+ ):
363
+ """
364
+ Determine if a pixel is on the edge based on neighbor count.
365
+
366
+ Parameters
367
+ ----------
368
+ x, y : float
369
+ Coordinates of the pixel being checked.
370
+ x_pos, y_pos : array-like
371
+ Arrays of x and y positions of all pixels.
372
+ module_ids : array-like
373
+ Array of module IDs corresponding to each pixel.
374
+ pixel_spacing : float
375
+ Center-to-center spacing between pixels.
376
+ module_gap : float
377
+ Additional gap between modules.
378
+ shape : int
379
+ Pixel shape type (0: circular, 1/3: hexagonal, 2: square).
380
+ current_module_id : int
381
+ Module ID of the current pixel.
382
+
383
+ Returns
384
+ -------
385
+ bool
386
+ True if the pixel is an edge pixel, False otherwise.
387
+ """
388
+ # Determine the maximum number of neighbors based on the pixel shape
389
+ if shape == 0: # Circular
390
+ max_neighbors = 8
391
+ elif shape in (1, 3): # Hexagonal
392
+ max_neighbors = 6
393
+ elif shape == 2: # Square
394
+ max_neighbors = 4
395
+ else:
396
+ raise ValueError(f"Unsupported pixel shape: {shape}")
397
+
398
+ neighbor_count = _count_neighbors(
399
+ x, y, x_pos, y_pos, module_ids, pixel_spacing, module_gap, current_module_id
400
+ )
401
+
402
+ # A pixel is an edge pixel if it has fewer neighbors than the maximum
403
+ return neighbor_count < max_neighbors
404
+
405
+
406
+ def _create_pixel_patches(
407
+ x_pos,
408
+ y_pos,
409
+ diameter,
410
+ module_number,
411
+ module_gap,
412
+ spacing,
413
+ shape,
414
+ pixels_on,
415
+ pixel_ids,
416
+ pixels_id_to_print,
417
+ telescope_model_name,
418
+ ):
419
+ """Create matplotlib patches for different pixel types.
420
+
421
+ This function creates the matplotlib patches (shapes) for all pixels in the
422
+ layout, categorizing them into "on", "edge", and "off" pixels based on their
423
+ status and position.
424
+
425
+ Parameters
426
+ ----------
427
+ x_pos, y_pos : array-like
428
+ X and Y coordinates of the pixel centers
429
+ diameter : float
430
+ Diameter of the pixels
431
+ module_number : array-like
432
+ Module numbers for each pixel
433
+ module_gap : float
434
+ Gap between modules
435
+ spacing : float
436
+ Pixel spacing
437
+ shape : array-like
438
+ Shape types for each pixel
439
+ pixels_on : array-like
440
+ Status indicating if each pixel is "on"
441
+ pixel_ids : array-like
442
+ Unique IDs for each pixel
443
+ pixels_id_to_print : int
444
+ Number of pixel IDs to print on the plot
445
+ telescope_model_name : str
446
+ Name of the telescope model
447
+
448
+ Returns
449
+ -------
450
+ tuple
451
+ Three lists of patches for "on", "edge", and "off" pixels
452
+ """
453
+ on_pixels, edge_pixels, off_pixels = [], [], []
454
+
455
+ array_element_type = names.get_array_element_type_from_name(telescope_model_name)
456
+ font_size = 2 if "SCT" in array_element_type else 4
457
+
458
+ for i, (x, y) in enumerate(zip(x_pos, y_pos)):
459
+ patch = _create_patch(x, y, diameter, shape)
460
+
461
+ if pixels_on[i]:
462
+ if _is_edge_pixel(
463
+ x, y, x_pos, y_pos, module_number, spacing, module_gap, shape, module_number[i]
464
+ ):
465
+ edge_pixels.append(patch)
466
+ else:
467
+ on_pixels.append(patch)
468
+ else:
469
+ off_pixels.append(patch)
470
+
471
+ if pixel_ids[i] < pixels_id_to_print:
472
+ plt.text(x, y, pixel_ids[i], ha="center", va="center", fontsize=font_size)
473
+
474
+ return on_pixels, edge_pixels, off_pixels
475
+
476
+
477
+ def _count_neighbors(x, y, x_pos, y_pos, module_ids, pixel_spacing, module_gap, current_module_id):
478
+ """
479
+ Count the number of neighboring pixels within the appropriate distance.
480
+
481
+ Parameters
482
+ ----------
483
+ x, y : float
484
+ Coordinates of the pixel being checked.
485
+ x_pos, y_pos : array-like
486
+ Arrays of x and y positions of all pixels.
487
+ module_ids : array-like
488
+ Array of module IDs corresponding to each pixel.
489
+ pixel_spacing : float
490
+ Center-to-center spacing between pixels.
491
+ module_gap : float
492
+ Additional gap between modules.
493
+ current_module_id : int
494
+ Module ID of the current pixel.
495
+
496
+ Returns
497
+ -------
498
+ int
499
+ Number of neighboring pixels.
500
+ """
501
+ count = 0
502
+ tolerance = 1e-6
503
+
504
+ for x2, y2, module_id2 in zip(x_pos, y_pos, module_ids):
505
+ # Skip the pixel itself
506
+ if x == x2 and y == y2:
507
+ continue
508
+
509
+ # Calculate the distance between the current pixel and the potential neighbor
510
+ dist = np.sqrt((x - x2) ** 2 + (y - y2) ** 2)
511
+
512
+ # Determine max distance based on whether pixels are in same module
513
+ max_distance = (
514
+ pixel_spacing + (0 if current_module_id == module_id2 else module_gap) + tolerance
515
+ ) * 1.2
516
+
517
+ if dist <= max_distance:
518
+ count += 1
519
+
520
+ return count
521
+
522
+
523
+ def _configure_plot(
524
+ ax,
525
+ x_pos,
526
+ y_pos,
527
+ rotation=0 * u.deg,
528
+ title=None,
529
+ xtitle=None,
530
+ ytitle=None,
531
+ ):
532
+ """Configure the plot with titles, labels, and limits.
533
+
534
+ Parameters
535
+ ----------
536
+ ax : matplotlib.axes.Axes
537
+ The axes to configure
538
+ x_pos, y_pos : array-like
539
+ Arrays of x and y positions of pixels
540
+ rotation : Astropy quantity in degrees, optional
541
+ Rotation angle in degrees, default 0
542
+ title : str, optional
543
+ Plot title
544
+ xtitle : str, optional
545
+ X-axis label
546
+ ytitle : str, optional
547
+ Y-axis label
548
+
549
+
550
+ Returns
551
+ -------
552
+ None
553
+ The function modifies the plot axes in place.
554
+ """
555
+ # First set the aspect ratio
556
+ ax.set_aspect("equal")
557
+
558
+ # Calculate the axis limits
559
+ x_min, x_max = min(x_pos), max(x_pos)
560
+ y_min, y_max = min(y_pos), max(y_pos)
561
+
562
+ # Add some padding
563
+ x_padding = (x_max - x_min) * 0.1
564
+ y_padding = (y_max - y_min) * 0.1
565
+
566
+ # Set limits with padding
567
+ ax.set_xlim(x_min - x_padding, x_max + x_padding)
568
+ ax.set_ylim(y_min - y_padding, y_max + y_padding)
569
+
570
+ plt.grid(True)
571
+ ax.set_axisbelow(True)
572
+
573
+ plt.xlabel(xtitle or "Horizontal scale [cm]", fontsize=18, labelpad=0)
574
+ plt.ylabel(ytitle or "Vertical scale [cm]", fontsize=18, labelpad=0)
575
+ ax.set_title(
576
+ title or "Pixel layout",
577
+ fontsize=15,
578
+ y=1.02,
579
+ )
580
+ plt.tick_params(axis="both", which="major", labelsize=15)
581
+
582
+ _add_coordinate_axes(ax, rotation)
583
+ x_min = min(x_pos) - (max(x_pos) - min(x_pos)) * 0.05
584
+ y_min = min(y_pos) - (max(y_pos) - min(y_pos)) * 0.05
585
+ ax.text(x_min, y_min, "For an observer facing the camera", fontsize=10, ha="left", va="bottom")
586
+
587
+
588
+ def _add_coordinate_axes(ax, rotation=0 * u.deg):
589
+ """Add coordinate system axes to the plot."""
590
+ # Setup dimensions and positions
591
+ x_min, x_max = ax.get_xlim()
592
+ y_min, y_max = ax.get_ylim()
593
+ plot_size = min(x_max - x_min, y_max - y_min)
594
+ axis_length = plot_size * 0.08
595
+
596
+ x_origin = x_max - axis_length * 1.0
597
+ y_origin_az = y_min + axis_length * 2.5
598
+ y_origin_pix = y_min + axis_length * 1.2
599
+
600
+ arrow_style = {
601
+ "head_width": axis_length * 0.15,
602
+ "head_length": axis_length * 0.15,
603
+ "width": axis_length * 0.02,
604
+ }
605
+ arrow_length = 0.6
606
+ is_sst = abs(rotation - (90.0 * u.deg)).value < 1.0
607
+ az_direction = 1 if is_sst else -1
608
+
609
+ def add_arrow_label(ox, oy, dx, dy, label, offset, color="black", ha="center", va="center"):
610
+ """Adding arrows with label."""
611
+ ax.arrow(ox, oy, dx, dy, fc=color, ec=color, **arrow_style)
612
+ if np.sqrt(dx**2 + dy**2) > 0: # If not zero vector
613
+ dir_unit = np.sqrt(dx**2 + dy**2)
614
+ ax.text(
615
+ ox + dx + dx / dir_unit * axis_length * offset,
616
+ oy + dy + dy / dir_unit * axis_length * offset,
617
+ label,
618
+ ha=ha,
619
+ va=va,
620
+ color=color,
621
+ fontsize=10,
622
+ fontweight="bold",
623
+ )
624
+
625
+ # Az-Alt axes
626
+ az_dx = az_direction * axis_length * arrow_length
627
+ add_arrow_label(
628
+ x_origin,
629
+ y_origin_az,
630
+ az_dx,
631
+ 0,
632
+ "Az",
633
+ 0.25,
634
+ "red",
635
+ ha="left" if az_direction > 0 else "right",
636
+ )
637
+ add_arrow_label(
638
+ x_origin, y_origin_az, 0, -axis_length * arrow_length, "Alt", 0.25, "red", va="top"
639
+ )
640
+
641
+ # Pixel coordinate axes
642
+ rot_angle = rotation.to(u.rad).value
643
+ x_direction = -1 if is_sst else 1
644
+ x_dir = x_direction * axis_length * arrow_length * np.cos(rot_angle)
645
+ y_dir = x_direction * axis_length * arrow_length * np.sin(rot_angle)
646
+ add_arrow_label(x_origin, y_origin_pix, x_dir, y_dir, "$\\mathrm{x}_\\mathrm{pix}$", 0.45)
647
+
648
+ y_dx = axis_length * arrow_length * np.sin(rot_angle)
649
+ y_dy = -axis_length * arrow_length * np.cos(rot_angle)
650
+ add_arrow_label(x_origin, y_origin_pix, y_dx, y_dy, "$\\mathrm{y}_\\mathrm{pix}$", 0.45)
651
+
652
+
653
+ def _add_legend(ax, on_pixels, off_pixels):
654
+ """Add legend to the plot."""
655
+ legend_objects = [leg_h.PixelObject(), leg_h.EdgePixelObject()]
656
+ legend_labels = ["Pixel", "Edge pixel"]
657
+
658
+ # Choose handler based on pixel shape
659
+ is_hex = isinstance(on_pixels[0], mpatches.RegularPolygon)
660
+ legend_handler_map = {
661
+ leg_h.PixelObject: leg_h.HexPixelHandler() if is_hex else leg_h.SquarePixelHandler(),
662
+ leg_h.EdgePixelObject: leg_h.HexEdgePixelHandler()
663
+ if is_hex
664
+ else leg_h.SquareEdgePixelHandler(),
665
+ leg_h.OffPixelObject: leg_h.HexOffPixelHandler()
666
+ if is_hex
667
+ else leg_h.SquareOffPixelHandler(),
668
+ }
669
+
670
+ if off_pixels:
671
+ legend_objects.append(leg_h.OffPixelObject())
672
+ legend_labels.append("Disabled pixel")
673
+
674
+ ax.legend(
675
+ legend_objects,
676
+ legend_labels,
677
+ handler_map=legend_handler_map,
678
+ prop={"size": 11},
679
+ loc="upper right",
680
+ )