gammasimtools 0.16.0__py3-none-any.whl → 0.17.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 (63) hide show
  1. {gammasimtools-0.16.0.dist-info → gammasimtools-0.17.0.dist-info}/METADATA +4 -2
  2. {gammasimtools-0.16.0.dist-info → gammasimtools-0.17.0.dist-info}/RECORD +60 -54
  3. {gammasimtools-0.16.0.dist-info → gammasimtools-0.17.0.dist-info}/WHEEL +1 -1
  4. {gammasimtools-0.16.0.dist-info → gammasimtools-0.17.0.dist-info}/entry_points.txt +3 -1
  5. simtools/_version.py +2 -2
  6. simtools/applications/derive_ctao_array_layouts.py +5 -5
  7. simtools/applications/generate_simtel_event_data.py +36 -46
  8. simtools/applications/merge_tables.py +104 -0
  9. simtools/applications/plot_array_layout.py +145 -258
  10. simtools/applications/production_derive_corsika_limits.py +35 -220
  11. simtools/applications/production_derive_statistics.py +77 -43
  12. simtools/applications/simulate_light_emission.py +1 -0
  13. simtools/applications/simulate_prod.py +30 -18
  14. simtools/applications/simulate_prod_htcondor_generator.py +0 -1
  15. simtools/applications/submit_array_layouts.py +93 -0
  16. simtools/applications/verify_simulation_model_production_tables.py +52 -0
  17. simtools/camera/camera_efficiency.py +3 -3
  18. simtools/configuration/commandline_parser.py +28 -34
  19. simtools/configuration/configurator.py +0 -4
  20. simtools/corsika/corsika_config.py +17 -12
  21. simtools/corsika/primary_particle.py +46 -13
  22. simtools/data_model/metadata_collector.py +7 -3
  23. simtools/db/db_handler.py +11 -11
  24. simtools/db/db_model_upload.py +2 -2
  25. simtools/io_operations/io_handler.py +2 -2
  26. simtools/io_operations/io_table_handler.py +345 -0
  27. simtools/job_execution/htcondor_script_generator.py +2 -2
  28. simtools/job_execution/job_manager.py +7 -121
  29. simtools/layout/array_layout_utils.py +385 -0
  30. simtools/model/array_model.py +5 -0
  31. simtools/model/model_repository.py +134 -0
  32. simtools/production_configuration/{calculate_statistical_errors_grid_point.py → calculate_statistical_uncertainties_grid_point.py} +101 -112
  33. simtools/production_configuration/derive_corsika_limits.py +239 -111
  34. simtools/production_configuration/derive_corsika_limits_grid.py +189 -0
  35. simtools/production_configuration/derive_production_statistics.py +57 -26
  36. simtools/production_configuration/derive_production_statistics_handler.py +70 -37
  37. simtools/production_configuration/interpolation_handler.py +296 -94
  38. simtools/ray_tracing/ray_tracing.py +7 -6
  39. simtools/reporting/docs_read_parameters.py +104 -62
  40. simtools/runners/corsika_simtel_runner.py +4 -1
  41. simtools/runners/runner_services.py +5 -4
  42. simtools/schemas/model_parameters/dsum_threshold.schema.yml +41 -0
  43. simtools/schemas/production_configuration_metrics.schema.yml +2 -2
  44. simtools/simtel/simtel_config_writer.py +34 -14
  45. simtools/simtel/simtel_io_event_reader.py +301 -194
  46. simtools/simtel/simtel_io_event_writer.py +207 -227
  47. simtools/simtel/simtel_io_file_info.py +9 -4
  48. simtools/simtel/simtel_io_metadata.py +20 -5
  49. simtools/simtel/simulator_array.py +2 -2
  50. simtools/simtel/simulator_light_emission.py +79 -34
  51. simtools/simtel/simulator_ray_tracing.py +2 -2
  52. simtools/simulator.py +101 -68
  53. simtools/testing/validate_output.py +4 -1
  54. simtools/utils/general.py +1 -1
  55. simtools/utils/names.py +5 -5
  56. simtools/visualization/plot_array_layout.py +242 -0
  57. simtools/visualization/plot_pixels.py +681 -0
  58. simtools/visualization/visualize.py +3 -219
  59. simtools/applications/production_generate_simulation_config.py +0 -152
  60. simtools/layout/ctao_array_layouts.py +0 -172
  61. simtools/production_configuration/generate_simulation_config.py +0 -158
  62. {gammasimtools-0.16.0.dist-info → gammasimtools-0.17.0.dist-info}/licenses/LICENSE +0 -0
  63. {gammasimtools-0.16.0.dist-info → gammasimtools-0.17.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,681 @@
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
+ plt.tight_layout()
250
+ return fig
251
+
252
+
253
+ def _read_pixel_config(dat_file_path):
254
+ """Read pixel configuration from a camera configuration file.
255
+
256
+ This function reads the pixel configuration from the specified camera config file and
257
+ returns it as a dictionary. It parses information such as pixel positions,
258
+ module numbers, and other relevant parameters.
259
+
260
+ Parameters
261
+ ----------
262
+ dat_file_path : str or Path
263
+ Path to the camera config file containing pixel configuration
264
+
265
+ Returns
266
+ -------
267
+ dict
268
+ config containing pixel data
269
+ """
270
+ config = {
271
+ "x": [],
272
+ "y": [],
273
+ "pixel_ids": [],
274
+ "pixels_on": [],
275
+ "pixel_shape": None,
276
+ "pixel_diameter": None,
277
+ "pixel_spacing": None,
278
+ "module_gap": None,
279
+ "trigger_groups": [],
280
+ "rotate_angle": None,
281
+ "module_number": [],
282
+ }
283
+
284
+ with open(dat_file_path, encoding="utf-8") as f:
285
+ for line in f:
286
+ line = line.strip()
287
+
288
+ if not line:
289
+ continue
290
+
291
+ # Parse specific information from the file
292
+ if line.startswith("Rotate"):
293
+ # Parse rotation angle from line like "Rotate 10.893" (u.deg)
294
+ config["rotate_angle"] = float(line.split()[1].strip()) * u.deg
295
+
296
+ elif line.startswith("PixType"):
297
+ parts = line.split()
298
+ config["pixel_shape"] = int(parts[5].strip())
299
+ config["pixel_diameter"] = float(parts[6].strip())
300
+
301
+ elif "Pixel spacing is" in line:
302
+ config["pixel_spacing"] = float(line.split("spacing is")[1].strip().split()[0])
303
+
304
+ elif "Between modules is an additional gap of" in line:
305
+ config["module_gap"] = float(line.split("gap of")[1].strip().split()[0])
306
+
307
+ elif line.startswith("Pixel"):
308
+ parts = line.split()
309
+ config["x"].append(float(parts[3].strip()))
310
+ config["y"].append(float(parts[4].strip()))
311
+ config["module_number"].append(float(parts[5].strip()))
312
+ config["pixel_ids"].append(int(parts[1].strip()))
313
+ config["pixels_on"].append(int(parts[9].strip()) != 0)
314
+
315
+ config["pixel_spacing"] = (
316
+ config["pixel_diameter"] if config["pixel_spacing"] is None else config["pixel_spacing"]
317
+ )
318
+ config["module_gap"] = 0.0 if config["module_gap"] is None else config["module_gap"]
319
+
320
+ return config
321
+
322
+
323
+ def _create_patch(x, y, diameter, shape):
324
+ """Create a single matplotlib patch for a pixel.
325
+
326
+ This function creates a matplotlib patch (shape) for a single pixel based on
327
+ its position, diameter, and shape type. Supported shapes are circles, squares,
328
+ and hexagons.
329
+
330
+ Parameters
331
+ ----------
332
+ x, y : float
333
+ Center coordinates of the pixel
334
+ diameter : float
335
+ Diameter of the pixel
336
+ shape : int
337
+ Pixel shape type:
338
+ 0: circular
339
+ 1: hexagonal (flat x)
340
+ 2: square
341
+ 3: hexagonal (flat y)
342
+
343
+ Returns
344
+ -------
345
+ matplotlib.patches.Patch
346
+ The created patch object for the pixel
347
+ """
348
+ if shape == 0: # Circular
349
+ return mpatches.Circle((x, y), radius=diameter / 2)
350
+ if shape in (1, 3): # Hexagonal
351
+ return mpatches.RegularPolygon(
352
+ (x, y),
353
+ numVertices=6,
354
+ radius=diameter / np.sqrt(3),
355
+ orientation=np.deg2rad(30 if shape == 3 else 0),
356
+ )
357
+ # Square
358
+ return mpatches.Rectangle((x - diameter / 2, y - diameter / 2), width=diameter, height=diameter)
359
+
360
+
361
+ def _is_edge_pixel(
362
+ x, y, x_pos, y_pos, module_ids, pixel_spacing, module_gap, shape, current_module_id
363
+ ):
364
+ """
365
+ Determine if a pixel is on the edge based on neighbor count.
366
+
367
+ Parameters
368
+ ----------
369
+ x, y : float
370
+ Coordinates of the pixel being checked.
371
+ x_pos, y_pos : array-like
372
+ Arrays of x and y positions of all pixels.
373
+ module_ids : array-like
374
+ Array of module IDs corresponding to each pixel.
375
+ pixel_spacing : float
376
+ Center-to-center spacing between pixels.
377
+ module_gap : float
378
+ Additional gap between modules.
379
+ shape : int
380
+ Pixel shape type (0: circular, 1/3: hexagonal, 2: square).
381
+ current_module_id : int
382
+ Module ID of the current pixel.
383
+
384
+ Returns
385
+ -------
386
+ bool
387
+ True if the pixel is an edge pixel, False otherwise.
388
+ """
389
+ # Determine the maximum number of neighbors based on the pixel shape
390
+ if shape == 0: # Circular
391
+ max_neighbors = 8
392
+ elif shape in (1, 3): # Hexagonal
393
+ max_neighbors = 6
394
+ elif shape == 2: # Square
395
+ max_neighbors = 4
396
+ else:
397
+ raise ValueError(f"Unsupported pixel shape: {shape}")
398
+
399
+ neighbor_count = _count_neighbors(
400
+ x, y, x_pos, y_pos, module_ids, pixel_spacing, module_gap, current_module_id
401
+ )
402
+
403
+ # A pixel is an edge pixel if it has fewer neighbors than the maximum
404
+ return neighbor_count < max_neighbors
405
+
406
+
407
+ def _create_pixel_patches(
408
+ x_pos,
409
+ y_pos,
410
+ diameter,
411
+ module_number,
412
+ module_gap,
413
+ spacing,
414
+ shape,
415
+ pixels_on,
416
+ pixel_ids,
417
+ pixels_id_to_print,
418
+ telescope_model_name,
419
+ ):
420
+ """Create matplotlib patches for different pixel types.
421
+
422
+ This function creates the matplotlib patches (shapes) for all pixels in the
423
+ layout, categorizing them into "on", "edge", and "off" pixels based on their
424
+ status and position.
425
+
426
+ Parameters
427
+ ----------
428
+ x_pos, y_pos : array-like
429
+ X and Y coordinates of the pixel centers
430
+ diameter : float
431
+ Diameter of the pixels
432
+ module_number : array-like
433
+ Module numbers for each pixel
434
+ module_gap : float
435
+ Gap between modules
436
+ spacing : float
437
+ Pixel spacing
438
+ shape : array-like
439
+ Shape types for each pixel
440
+ pixels_on : array-like
441
+ Status indicating if each pixel is "on"
442
+ pixel_ids : array-like
443
+ Unique IDs for each pixel
444
+ pixels_id_to_print : int
445
+ Number of pixel IDs to print on the plot
446
+ telescope_model_name : str
447
+ Name of the telescope model
448
+
449
+ Returns
450
+ -------
451
+ tuple
452
+ Three lists of patches for "on", "edge", and "off" pixels
453
+ """
454
+ on_pixels, edge_pixels, off_pixels = [], [], []
455
+
456
+ array_element_type = names.get_array_element_type_from_name(telescope_model_name)
457
+ font_size = 2 if "SCT" in array_element_type else 4
458
+
459
+ for i, (x, y) in enumerate(zip(x_pos, y_pos)):
460
+ patch = _create_patch(x, y, diameter, shape)
461
+
462
+ if pixels_on[i]:
463
+ if _is_edge_pixel(
464
+ x, y, x_pos, y_pos, module_number, spacing, module_gap, shape, module_number[i]
465
+ ):
466
+ edge_pixels.append(patch)
467
+ else:
468
+ on_pixels.append(patch)
469
+ else:
470
+ off_pixels.append(patch)
471
+
472
+ if pixel_ids[i] < pixels_id_to_print:
473
+ plt.text(x, y, pixel_ids[i], ha="center", va="center", fontsize=font_size)
474
+
475
+ return on_pixels, edge_pixels, off_pixels
476
+
477
+
478
+ def _count_neighbors(x, y, x_pos, y_pos, module_ids, pixel_spacing, module_gap, current_module_id):
479
+ """
480
+ Count the number of neighboring pixels within the appropriate distance.
481
+
482
+ Parameters
483
+ ----------
484
+ x, y : float
485
+ Coordinates of the pixel being checked.
486
+ x_pos, y_pos : array-like
487
+ Arrays of x and y positions of all pixels.
488
+ module_ids : array-like
489
+ Array of module IDs corresponding to each pixel.
490
+ pixel_spacing : float
491
+ Center-to-center spacing between pixels.
492
+ module_gap : float
493
+ Additional gap between modules.
494
+ current_module_id : int
495
+ Module ID of the current pixel.
496
+
497
+ Returns
498
+ -------
499
+ int
500
+ Number of neighboring pixels.
501
+ """
502
+ count = 0
503
+ tolerance = 1e-6
504
+
505
+ for x2, y2, module_id2 in zip(x_pos, y_pos, module_ids):
506
+ # Skip the pixel itself
507
+ if x == x2 and y == y2:
508
+ continue
509
+
510
+ # Calculate the distance between the current pixel and the potential neighbor
511
+ dist = np.sqrt((x - x2) ** 2 + (y - y2) ** 2)
512
+
513
+ # Determine max distance based on whether pixels are in same module
514
+ max_distance = (
515
+ pixel_spacing + (0 if current_module_id == module_id2 else module_gap) + tolerance
516
+ ) * 1.2
517
+
518
+ if dist <= max_distance:
519
+ count += 1
520
+
521
+ return count
522
+
523
+
524
+ def _configure_plot(
525
+ ax,
526
+ x_pos,
527
+ y_pos,
528
+ rotation=0 * u.deg,
529
+ title=None,
530
+ xtitle=None,
531
+ ytitle=None,
532
+ ):
533
+ """Configure the plot with titles, labels, and limits.
534
+
535
+ Parameters
536
+ ----------
537
+ ax : matplotlib.axes.Axes
538
+ The axes to configure
539
+ x_pos, y_pos : array-like
540
+ Arrays of x and y positions of pixels
541
+ rotation : Astropy quantity in degrees, optional
542
+ Rotation angle in degrees, default 0
543
+ title : str, optional
544
+ Plot title
545
+ xtitle : str, optional
546
+ X-axis label
547
+ ytitle : str, optional
548
+ Y-axis label
549
+
550
+
551
+ Returns
552
+ -------
553
+ None
554
+ The function modifies the plot axes in place.
555
+ """
556
+ # First set the aspect ratio
557
+ ax.set_aspect("equal")
558
+
559
+ # Calculate the axis limits
560
+ x_min, x_max = min(x_pos), max(x_pos)
561
+ y_min, y_max = min(y_pos), max(y_pos)
562
+
563
+ # Add some padding
564
+ x_padding = (x_max - x_min) * 0.1
565
+ y_padding = (y_max - y_min) * 0.1
566
+
567
+ # Set limits with padding
568
+ ax.set_xlim(x_min - x_padding, x_max + x_padding)
569
+ ax.set_ylim(y_min - y_padding, y_max + y_padding)
570
+
571
+ plt.grid(True)
572
+ ax.set_axisbelow(True)
573
+
574
+ plt.xlabel(xtitle or "Horizontal scale [cm]", fontsize=18, labelpad=0)
575
+ plt.ylabel(ytitle or "Vertical scale [cm]", fontsize=18, labelpad=0)
576
+ ax.set_title(
577
+ title or "Pixel layout",
578
+ fontsize=15,
579
+ y=1.02,
580
+ )
581
+ plt.tick_params(axis="both", which="major", labelsize=15)
582
+
583
+ _add_coordinate_axes(ax, rotation)
584
+ x_min = min(x_pos) - (max(x_pos) - min(x_pos)) * 0.05
585
+ y_min = min(y_pos) - (max(y_pos) - min(y_pos)) * 0.05
586
+ ax.text(x_min, y_min, "For an observer facing the camera", fontsize=10, ha="left", va="bottom")
587
+
588
+
589
+ def _add_coordinate_axes(ax, rotation=0 * u.deg):
590
+ """Add coordinate system axes to the plot."""
591
+ # Setup dimensions and positions
592
+ x_min, x_max = ax.get_xlim()
593
+ y_min, y_max = ax.get_ylim()
594
+ plot_size = min(x_max - x_min, y_max - y_min)
595
+ axis_length = plot_size * 0.08
596
+
597
+ x_origin = x_max - axis_length * 1.0
598
+ y_origin_az = y_min + axis_length * 2.5
599
+ y_origin_pix = y_min + axis_length * 1.2
600
+
601
+ arrow_style = {
602
+ "head_width": axis_length * 0.15,
603
+ "head_length": axis_length * 0.15,
604
+ "width": axis_length * 0.02,
605
+ }
606
+ arrow_length = 0.6
607
+ is_sst = abs(rotation - (90.0 * u.deg)).value < 1.0
608
+ az_direction = 1 if is_sst else -1
609
+
610
+ def add_arrow_label(ox, oy, dx, dy, label, offset, color="black", ha="center", va="center"):
611
+ """Adding arrows with label."""
612
+ ax.arrow(ox, oy, dx, dy, fc=color, ec=color, **arrow_style)
613
+ if np.sqrt(dx**2 + dy**2) > 0: # If not zero vector
614
+ dir_unit = np.sqrt(dx**2 + dy**2)
615
+ ax.text(
616
+ ox + dx + dx / dir_unit * axis_length * offset,
617
+ oy + dy + dy / dir_unit * axis_length * offset,
618
+ label,
619
+ ha=ha,
620
+ va=va,
621
+ color=color,
622
+ fontsize=10,
623
+ fontweight="bold",
624
+ )
625
+
626
+ # Az-Alt axes
627
+ az_dx = az_direction * axis_length * arrow_length
628
+ add_arrow_label(
629
+ x_origin,
630
+ y_origin_az,
631
+ az_dx,
632
+ 0,
633
+ "Az",
634
+ 0.25,
635
+ "red",
636
+ ha="left" if az_direction > 0 else "right",
637
+ )
638
+ add_arrow_label(
639
+ x_origin, y_origin_az, 0, -axis_length * arrow_length, "Alt", 0.25, "red", va="top"
640
+ )
641
+
642
+ # Pixel coordinate axes
643
+ rot_angle = rotation.to(u.rad).value
644
+ x_direction = -1 if is_sst else 1
645
+ x_dir = x_direction * axis_length * arrow_length * np.cos(rot_angle)
646
+ y_dir = x_direction * axis_length * arrow_length * np.sin(rot_angle)
647
+ add_arrow_label(x_origin, y_origin_pix, x_dir, y_dir, "$\\mathrm{x}_\\mathrm{pix}$", 0.45)
648
+
649
+ y_dx = axis_length * arrow_length * np.sin(rot_angle)
650
+ y_dy = -axis_length * arrow_length * np.cos(rot_angle)
651
+ add_arrow_label(x_origin, y_origin_pix, y_dx, y_dy, "$\\mathrm{y}_\\mathrm{pix}$", 0.45)
652
+
653
+
654
+ def _add_legend(ax, on_pixels, off_pixels):
655
+ """Add legend to the plot."""
656
+ legend_objects = [leg_h.PixelObject(), leg_h.EdgePixelObject()]
657
+ legend_labels = ["Pixel", "Edge pixel"]
658
+
659
+ # Choose handler based on pixel shape
660
+ is_hex = isinstance(on_pixels[0], mpatches.RegularPolygon)
661
+ legend_handler_map = {
662
+ leg_h.PixelObject: leg_h.HexPixelHandler() if is_hex else leg_h.SquarePixelHandler(),
663
+ leg_h.EdgePixelObject: leg_h.HexEdgePixelHandler()
664
+ if is_hex
665
+ else leg_h.SquareEdgePixelHandler(),
666
+ leg_h.OffPixelObject: leg_h.HexOffPixelHandler()
667
+ if is_hex
668
+ else leg_h.SquareOffPixelHandler(),
669
+ }
670
+
671
+ if off_pixels:
672
+ legend_objects.append(leg_h.OffPixelObject())
673
+ legend_labels.append("Disabled pixel")
674
+
675
+ ax.legend(
676
+ legend_objects,
677
+ legend_labels,
678
+ handler_map=legend_handler_map,
679
+ prop={"size": 11},
680
+ loc="upper right",
681
+ )