gammasimtools 0.24.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 (138) hide show
  1. {gammasimtools-0.24.0.dist-info → gammasimtools-0.26.0.dist-info}/METADATA +2 -1
  2. {gammasimtools-0.24.0.dist-info → gammasimtools-0.26.0.dist-info}/RECORD +134 -130
  3. {gammasimtools-0.24.0.dist-info → gammasimtools-0.26.0.dist-info}/entry_points.txt +3 -1
  4. {gammasimtools-0.24.0.dist-info → gammasimtools-0.26.0.dist-info}/licenses/LICENSE +1 -1
  5. simtools/_version.py +2 -2
  6. simtools/application_control.py +78 -0
  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 +5 -1
  21. simtools/applications/derive_pulse_shape_parameters.py +194 -0
  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 +64 -108
  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 +3 -4
  41. simtools/applications/simulate_illuminator.py +0 -1
  42. simtools/applications/simulate_pedestals.py +2 -6
  43. simtools/applications/simulate_prod.py +9 -28
  44. simtools/applications/simulate_prod_htcondor_generator.py +8 -1
  45. simtools/applications/submit_array_layouts.py +7 -7
  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_file_using_schema.py +49 -123
  51. simtools/applications/validate_optics.py +0 -13
  52. simtools/camera/camera_efficiency.py +1 -6
  53. simtools/camera/single_photon_electron_spectrum.py +2 -1
  54. simtools/configuration/commandline_parser.py +43 -8
  55. simtools/configuration/configurator.py +6 -11
  56. simtools/corsika/corsika_config.py +204 -99
  57. simtools/corsika/corsika_histograms.py +411 -1735
  58. simtools/corsika/primary_particle.py +1 -1
  59. simtools/data_model/metadata_collector.py +5 -2
  60. simtools/data_model/metadata_model.py +0 -4
  61. simtools/data_model/model_data_writer.py +27 -17
  62. simtools/data_model/schema.py +112 -5
  63. simtools/data_model/validate_data.py +80 -48
  64. simtools/db/db_handler.py +19 -8
  65. simtools/db/db_model_upload.py +2 -1
  66. simtools/db/mongo_db.py +133 -42
  67. simtools/dependencies.py +83 -44
  68. simtools/io/ascii_handler.py +4 -2
  69. simtools/io/table_handler.py +1 -1
  70. simtools/job_execution/htcondor_script_generator.py +0 -2
  71. simtools/layout/array_layout.py +4 -12
  72. simtools/layout/array_layout_utils.py +227 -58
  73. simtools/model/array_model.py +37 -18
  74. simtools/model/calibration_model.py +0 -4
  75. simtools/model/legacy_model_parameter.py +134 -0
  76. simtools/model/model_parameter.py +24 -14
  77. simtools/model/model_repository.py +18 -5
  78. simtools/model/model_utils.py +1 -6
  79. simtools/model/site_model.py +0 -4
  80. simtools/model/telescope_model.py +6 -11
  81. simtools/production_configuration/derive_corsika_limits.py +6 -11
  82. simtools/production_configuration/interpolation_handler.py +16 -16
  83. simtools/ray_tracing/incident_angles.py +5 -11
  84. simtools/ray_tracing/mirror_panel_psf.py +3 -7
  85. simtools/ray_tracing/psf_analysis.py +29 -27
  86. simtools/ray_tracing/psf_parameter_optimisation.py +822 -680
  87. simtools/ray_tracing/ray_tracing.py +6 -15
  88. simtools/reporting/docs_auto_report_generator.py +8 -13
  89. simtools/reporting/docs_read_parameters.py +70 -16
  90. simtools/runners/corsika_runner.py +15 -10
  91. simtools/runners/corsika_simtel_runner.py +9 -8
  92. simtools/runners/runner_services.py +17 -7
  93. simtools/runners/simtel_runner.py +11 -58
  94. simtools/runners/simtools_runner.py +2 -4
  95. simtools/schemas/model_parameters/flasher_pulse_exp_decay.schema.yml +2 -0
  96. simtools/schemas/model_parameters/flasher_pulse_shape.schema.yml +50 -0
  97. simtools/schemas/model_parameters/flasher_pulse_width.schema.yml +2 -0
  98. simtools/schemas/simulation_models_info.schema.yml +2 -0
  99. simtools/settings.py +154 -0
  100. simtools/sim_events/file_info.py +128 -0
  101. simtools/{simtel/simtel_io_event_histograms.py → sim_events/histograms.py} +25 -15
  102. simtools/{simtel/simtel_io_event_reader.py → sim_events/reader.py} +20 -17
  103. simtools/{simtel/simtel_io_event_writer.py → sim_events/writer.py} +84 -25
  104. simtools/simtel/pulse_shapes.py +273 -0
  105. simtools/simtel/simtel_config_writer.py +146 -22
  106. simtools/simtel/simtel_table_reader.py +6 -4
  107. simtools/simtel/simulator_array.py +62 -23
  108. simtools/simtel/simulator_camera_efficiency.py +4 -6
  109. simtools/simtel/simulator_light_emission.py +101 -19
  110. simtools/simtel/simulator_ray_tracing.py +4 -10
  111. simtools/simulator.py +360 -353
  112. simtools/telescope_trigger_rates.py +3 -4
  113. simtools/testing/assertions.py +115 -8
  114. simtools/testing/configuration.py +2 -3
  115. simtools/testing/helpers.py +2 -3
  116. simtools/testing/log_inspector.py +5 -1
  117. simtools/testing/sim_telarray_metadata.py +1 -1
  118. simtools/testing/validate_output.py +69 -23
  119. simtools/utils/general.py +37 -0
  120. simtools/utils/geometry.py +0 -77
  121. simtools/utils/names.py +7 -9
  122. simtools/version.py +37 -0
  123. simtools/visualization/legend_handlers.py +21 -10
  124. simtools/visualization/plot_array_layout.py +312 -41
  125. simtools/visualization/plot_corsika_histograms.py +143 -605
  126. simtools/visualization/plot_mirrors.py +834 -0
  127. simtools/visualization/plot_pixels.py +2 -4
  128. simtools/visualization/plot_psf.py +0 -1
  129. simtools/visualization/plot_simtel_event_histograms.py +4 -4
  130. simtools/visualization/plot_simtel_events.py +6 -11
  131. simtools/visualization/plot_tables.py +8 -19
  132. simtools/visualization/visualize.py +22 -2
  133. simtools/applications/db_development_tools/write_array_elements_positions_to_repository.py +0 -160
  134. simtools/applications/print_version.py +0 -53
  135. simtools/io/hdf5_handler.py +0 -139
  136. simtools/simtel/simtel_io_file_info.py +0 -62
  137. {gammasimtools-0.24.0.dist-info → gammasimtools-0.26.0.dist-info}/WHEEL +0 -0
  138. {gammasimtools-0.24.0.dist-info → gammasimtools-0.26.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,834 @@
1
+ #!/usr/bin/python3
2
+ """Functions for plotting mirror panel layout information."""
3
+
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import matplotlib.colors as mcolors
8
+ import matplotlib.patches as mpatches
9
+ import matplotlib.pyplot as plt
10
+ import numpy as np
11
+ from matplotlib.collections import PatchCollection
12
+
13
+ from simtools.io import io_handler
14
+ from simtools.model.mirrors import Mirrors
15
+ from simtools.model.telescope_model import TelescopeModel
16
+ from simtools.visualization import visualize
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ PATCH_STYLE = {"alpha": 0.8, "edgecolor": "black", "facecolor": "dodgerblue"}
21
+ LABEL_STYLE = {"ha": "center", "va": "center", "fontsize": 10, "color": "white", "weight": "bold"}
22
+ STATS_BOX_STYLE = {"boxstyle": "round", "facecolor": "wheat", "alpha": 0.8}
23
+ OBSERVER_TEXT = "for an observer facing the mirrors"
24
+
25
+
26
+ def _rotate_coordinates_clockwise_90(x_pos, y_pos):
27
+ """
28
+ Rotate coordinates 90 degrees clockwise for observer-facing view.
29
+
30
+ Plots are rotated by 90 degrees clockwise to present the point of view of a
31
+ person standing on the camera platform.
32
+
33
+ Important: Any transformation applied to one mirror list must be applied consistently to
34
+ all (primary) mirror lists. Inconsistent transformations would result in incorrect mirror
35
+ configurations when running sim_telarray simulations.
36
+ """
37
+ return y_pos, -x_pos
38
+
39
+
40
+ def _detect_segmentation_type(data_file_path):
41
+ """
42
+ Detect the type of segmentation file (ring, shape, or standard).
43
+
44
+ Parameters
45
+ ----------
46
+ data_file_path : Path
47
+ Path to the segmentation data file
48
+
49
+ Returns
50
+ -------
51
+ str
52
+ One of "ring", "shape", or "standard"
53
+ """
54
+ with open(data_file_path, encoding="utf-8") as f:
55
+ for line in f:
56
+ line_lower = line.strip().lower()
57
+ if line_lower.startswith("#") or not line_lower:
58
+ continue
59
+ if line_lower.startswith("ring"):
60
+ return "ring"
61
+ if line_lower.startswith(("hex", "yhex")):
62
+ return "shape"
63
+ return "standard"
64
+
65
+
66
+ def plot(config, output_file):
67
+ """
68
+ Plot mirror panel layout based on configuration.
69
+
70
+ Parameters
71
+ ----------
72
+ config : dict
73
+ Configuration dictionary containing:
74
+ - parameter: str, parameter name (e.g., "mirror_list", "primary_mirror_segmentation")
75
+ - site: str, site name (e.g., "North", "South")
76
+ - telescope: str, telescope name (e.g., "LSTN-01")
77
+ - parameter_version: str, optional, parameter version
78
+ - model_version: str, optional, model version
79
+ - title: str, optional, plot title
80
+ output_file : str or Path
81
+ Path where to save the plot (without extension)
82
+
83
+ Returns
84
+ -------
85
+ None
86
+ The function saves the plot to the specified output file.
87
+ """
88
+ tel_model = TelescopeModel(
89
+ site=config["site"],
90
+ telescope_name=config["telescope"],
91
+ model_version=config.get("model_version"),
92
+ ignore_software_version=True,
93
+ )
94
+
95
+ output_path = io_handler.IOHandler().get_output_directory()
96
+
97
+ parameter_name = config["parameter"]
98
+ parameter_value = tel_model.get_parameter_value(parameter_name)
99
+ tel_model.export_model_files(destination_path=output_path)
100
+
101
+ mirror_file = parameter_value
102
+ data_file_path = Path(output_path / mirror_file)
103
+
104
+ parameter_type = config["parameter"]
105
+
106
+ if parameter_type in ("primary_mirror_segmentation", "secondary_mirror_segmentation"):
107
+ segmentation_type = _detect_segmentation_type(data_file_path)
108
+
109
+ if segmentation_type == "ring":
110
+ fig = plot_mirror_ring_segmentation(
111
+ data_file_path=data_file_path,
112
+ telescope_model_name=config["telescope"],
113
+ parameter_type=parameter_type,
114
+ )
115
+ elif segmentation_type == "shape":
116
+ fig = plot_mirror_shape_segmentation(
117
+ data_file_path=data_file_path,
118
+ telescope_model_name=config["telescope"],
119
+ parameter_type=parameter_type,
120
+ )
121
+ else:
122
+ fig = plot_mirror_segmentation(
123
+ data_file_path=data_file_path,
124
+ telescope_model_name=config["telescope"],
125
+ parameter_type=parameter_type,
126
+ )
127
+ else:
128
+ mirrors = Mirrors(mirror_list_file=data_file_path)
129
+ fig = plot_mirror_layout(
130
+ mirrors=mirrors,
131
+ mirror_file_path=data_file_path,
132
+ telescope_model_name=config["telescope"],
133
+ )
134
+
135
+ visualize.save_figure(fig, output_file)
136
+ plt.close(fig)
137
+
138
+
139
+ def plot_mirror_layout(mirrors, mirror_file_path, telescope_model_name):
140
+ """
141
+ Plot the mirror panel layout from a Mirrors object.
142
+
143
+ Parameters
144
+ ----------
145
+ mirrors : Mirrors
146
+ Mirrors object containing mirror panel data including positions,
147
+ diameters, focal lengths, and shape types
148
+ mirror_file_path : Path or str
149
+ Path to the mirror list file
150
+ telescope_model_name : str
151
+ Name of the telescope model (e.g., "LSTN-01", "MSTN-01")
152
+
153
+ Returns
154
+ -------
155
+ matplotlib.figure.Figure
156
+ The generated figure object
157
+ """
158
+ logger.info(f"Plotting mirror layout for {telescope_model_name}")
159
+
160
+ fig, ax = plt.subplots(figsize=(10, 10))
161
+
162
+ x_pos = mirrors.mirror_table["mirror_x"].to("cm").value
163
+ y_pos = mirrors.mirror_table["mirror_y"].to("cm").value
164
+ x_pos, y_pos = _rotate_coordinates_clockwise_90(x_pos, y_pos)
165
+ diameter = mirrors.mirror_diameter.to("cm").value
166
+ shape_type = mirrors.shape_type
167
+ focal_lengths = mirrors.mirror_table["focal_length"].to("cm").value
168
+
169
+ mirror_ids = (
170
+ mirrors.mirror_table["mirror_panel_id"]
171
+ if "mirror_panel_id" in mirrors.mirror_table.colnames
172
+ else list(range(len(x_pos)))
173
+ )
174
+
175
+ # MST mirrors are numbered from 1 at the bottom to N at the top
176
+ if telescope_model_name and "MST" in telescope_model_name.upper():
177
+ n_mirrors = len(mirror_ids)
178
+ mirror_ids = [n_mirrors - mid for mid in mirror_ids]
179
+
180
+ patches, colors = _create_mirror_patches(x_pos, y_pos, diameter, shape_type, focal_lengths)
181
+
182
+ collection = PatchCollection(
183
+ patches,
184
+ cmap="viridis",
185
+ edgecolor="black",
186
+ linewidth=0.5,
187
+ )
188
+ collection.set_array(np.array(colors))
189
+ ax.add_collection(collection)
190
+
191
+ _add_mirror_labels(ax, x_pos, y_pos, mirror_ids, max_labels=20)
192
+
193
+ _configure_mirror_plot(ax, x_pos, y_pos)
194
+
195
+ cbar = plt.colorbar(collection, ax=ax, pad=0.02)
196
+ cbar.set_label("Focal length [cm]", fontsize=14)
197
+
198
+ _add_mirror_statistics(ax, mirrors, mirror_file_path, x_pos, y_pos, diameter)
199
+
200
+ return fig
201
+
202
+
203
+ def plot_mirror_segmentation(data_file_path, telescope_model_name, parameter_type):
204
+ """
205
+ Plot mirror segmentation layout from a segmentation file.
206
+
207
+ Parameters
208
+ ----------
209
+ data_file_path : Path or str
210
+ Path to the segmentation data file containing mirror segment positions,
211
+ diameters, and shape types in standard numeric format
212
+ telescope_model_name : str
213
+ Name of the telescope model (e.g., "LSTN-01", "MSTN-01")
214
+ parameter_type : str
215
+ Type of segmentation parameter (e.g., "primary_mirror_segmentation",
216
+ "secondary_mirror_segmentation")
217
+
218
+ Returns
219
+ -------
220
+ matplotlib.figure.Figure
221
+ The generated figure object
222
+ """
223
+ logger.info(f"Plotting {parameter_type} for {telescope_model_name}")
224
+
225
+ segmentation_data = _read_segmentation_file(data_file_path)
226
+
227
+ fig, ax = plt.subplots(figsize=(10, 10))
228
+
229
+ x_pos = segmentation_data["x"]
230
+ y_pos = segmentation_data["y"]
231
+ x_pos, y_pos = _rotate_coordinates_clockwise_90(x_pos, y_pos)
232
+ diameter = segmentation_data["diameter"]
233
+ shape_type = segmentation_data["shape_type"]
234
+ segment_ids = segmentation_data["segment_ids"]
235
+
236
+ patches, colors = _create_mirror_patches(x_pos, y_pos, diameter, shape_type, segment_ids)
237
+
238
+ collection = PatchCollection(
239
+ patches,
240
+ cmap="tab20",
241
+ edgecolor="black",
242
+ linewidth=0.8,
243
+ )
244
+ collection.set_array(np.array(colors))
245
+ ax.add_collection(collection)
246
+
247
+ _add_mirror_labels(ax, x_pos, y_pos, segment_ids, max_labels=30)
248
+
249
+ _configure_mirror_plot(ax, x_pos, y_pos)
250
+
251
+ cbar = plt.colorbar(collection, ax=ax, pad=0.02)
252
+ cbar.set_label("Segment ID", fontsize=14)
253
+
254
+ n_segments = len(set(segment_ids))
255
+ stats_text = (
256
+ f"Number of segments: {len(x_pos)}\n"
257
+ f"Number of segment groups: {n_segments}\n"
258
+ f"Segment diameter: {diameter:.1f} cm"
259
+ )
260
+
261
+ ax.text(
262
+ 0.02,
263
+ 0.98,
264
+ stats_text,
265
+ transform=ax.transAxes,
266
+ fontsize=11,
267
+ verticalalignment="top",
268
+ bbox=STATS_BOX_STYLE,
269
+ )
270
+
271
+ return fig
272
+
273
+
274
+ def _create_mirror_patches(x_pos, y_pos, diameter, shape_type, color_values):
275
+ """Create matplotlib patches for mirror panels or segments."""
276
+ patches = [
277
+ _create_single_mirror_patch(x, y, diameter, shape_type) for x, y in zip(x_pos, y_pos)
278
+ ]
279
+ return patches, list(color_values)
280
+
281
+
282
+ def _read_segmentation_file(data_file_path):
283
+ """Read mirror segmentation file and extract segment information."""
284
+ x_pos = []
285
+ y_pos = []
286
+ diameter = None
287
+ shape_type = None
288
+ segment_ids = []
289
+
290
+ with open(data_file_path, encoding="utf-8") as f:
291
+ for line in f:
292
+ line = line.strip()
293
+ if not line or line.startswith("#"):
294
+ continue
295
+
296
+ parts = line.split()
297
+ if len(parts) < 5:
298
+ continue
299
+
300
+ try:
301
+ x_pos.append(float(parts[0]))
302
+ y_pos.append(float(parts[1]))
303
+ except ValueError:
304
+ continue
305
+
306
+ diameter = _extract_diameter(parts, diameter)
307
+ shape_type = _extract_shape_type(parts, shape_type)
308
+ segment_ids.append(_extract_segment_id(parts, len(segment_ids)))
309
+
310
+ if len(x_pos) == 0:
311
+ logger.warning(f"No valid numeric data found in segmentation file: {data_file_path}")
312
+
313
+ return {
314
+ "x": np.array(x_pos),
315
+ "y": np.array(y_pos),
316
+ "diameter": diameter if diameter is not None else 150.0,
317
+ "shape_type": shape_type if shape_type is not None else 3,
318
+ "segment_ids": segment_ids,
319
+ }
320
+
321
+
322
+ def _extract_diameter(parts, current_diameter):
323
+ """Extract diameter from parts or return current value."""
324
+ return float(parts[2]) if current_diameter is None else current_diameter
325
+
326
+
327
+ def _extract_shape_type(parts, current_shape_type):
328
+ """Extract shape type from parts or return current value."""
329
+ return int(parts[4]) if current_shape_type is None else current_shape_type
330
+
331
+
332
+ def _extract_segment_id(parts, default_id):
333
+ """Extract segment ID from parts or return default."""
334
+ if len(parts) >= 8:
335
+ seg_id_str = parts[7].split("=")[-1] if "=" in parts[7] else parts[7]
336
+ return int("".join(filter(str.isdigit, seg_id_str)))
337
+ return default_id
338
+
339
+
340
+ def _create_single_mirror_patch(x, y, diameter, shape_type):
341
+ """Create a single matplotlib patch for a mirror panel (hexagonal only)."""
342
+ base_orientation = 0 if shape_type == 1 else np.pi / 2
343
+ # Rotate hexagon counter-clockwise to compensate for clockwise coordinate rotation
344
+ orientation = base_orientation - np.pi / 2
345
+
346
+ return mpatches.RegularPolygon(
347
+ (x, y),
348
+ numVertices=6,
349
+ radius=diameter / np.sqrt(3),
350
+ orientation=orientation,
351
+ )
352
+
353
+
354
+ def _add_mirror_labels(ax, x_pos, y_pos, mirror_ids, max_labels=20):
355
+ """Add mirror panel ID labels to the plot."""
356
+ mirror_data = sorted(zip(mirror_ids, x_pos, y_pos), key=lambda item: item[0])
357
+
358
+ for i, (mid, x, y) in enumerate(mirror_data):
359
+ if i < max_labels:
360
+ ax.text(
361
+ x,
362
+ y,
363
+ str(mid),
364
+ ha="center",
365
+ va="center",
366
+ fontsize=6,
367
+ color="white",
368
+ weight="bold",
369
+ )
370
+
371
+
372
+ def _configure_mirror_plot(ax, x_pos, y_pos):
373
+ """Add titles, labels, and limits."""
374
+ ax.set_aspect("equal")
375
+
376
+ if len(x_pos) == 0 or len(y_pos) == 0:
377
+ logger.warning("No valid mirror data found for plotting")
378
+ ax.set_xlim(-1000, 1000)
379
+ ax.set_ylim(-1000, 1000)
380
+ ax.text(0, 0, "No valid mirror data", ha="center", va="center", fontsize=14, color="red")
381
+ return
382
+
383
+ x_min, x_max = np.min(x_pos), np.max(x_pos)
384
+ y_min, y_max = np.min(y_pos), np.max(y_pos)
385
+
386
+ x_padding = (x_max - x_min) * 0.15
387
+ y_padding = (y_max - y_min) * 0.15
388
+
389
+ ax.set_xlim(x_min - x_padding, x_max + x_padding)
390
+ ax.set_ylim(y_min - y_padding, y_max + y_padding)
391
+
392
+ plt.xlabel("X position [cm]", fontsize=14)
393
+ plt.ylabel("Y position [cm]", fontsize=14)
394
+ plt.grid(True, alpha=0.3)
395
+ plt.tick_params(axis="both", which="major", labelsize=12)
396
+
397
+ ax.text(
398
+ 0.02,
399
+ 0.02,
400
+ OBSERVER_TEXT,
401
+ transform=ax.transAxes,
402
+ fontsize=10,
403
+ ha="left",
404
+ va="bottom",
405
+ style="italic",
406
+ color="black",
407
+ )
408
+
409
+
410
+ def _extract_float_after_keyword(line, keyword):
411
+ """Extract first float value after keyword in line."""
412
+ if keyword not in line:
413
+ return None
414
+ try:
415
+ part = line.split(keyword, 1)[1] if keyword == "=" else line.split(keyword)[-1]
416
+ return float(part.strip().split()[0])
417
+ except (ValueError, IndexError):
418
+ return None
419
+
420
+
421
+ def _read_mirror_file_metadata(mirror_file_path):
422
+ """Read metadata from mirror .dat file header (Rmax and total surface area)."""
423
+ metadata = {}
424
+ patterns = [
425
+ (("Total surface area:", "Total mirror surface area:"), ":", "total_surface_area"),
426
+ (("Rmax =", "Rmax="), "=", "rmax"),
427
+ (("mirrors are inside a radius of",), "of", "rmax"),
428
+ ]
429
+
430
+ try:
431
+ with open(mirror_file_path, encoding="utf-8") as f:
432
+ for line in f:
433
+ if not line.startswith("#"):
434
+ break
435
+ for triggers, keyword, key in patterns:
436
+ if key not in metadata and any(t in line for t in triggers):
437
+ if (val := _extract_float_after_keyword(line, keyword)) is not None:
438
+ metadata[key] = val
439
+ break
440
+ except OSError as e:
441
+ logger.warning(f"Could not read mirror file metadata: {e}")
442
+
443
+ return metadata
444
+
445
+
446
+ def _add_mirror_statistics(ax, mirrors, mirror_file_path, x_pos, y_pos, diameter):
447
+ """Add mirror statistics text to the plot."""
448
+ n_mirrors = mirrors.number_of_mirrors
449
+
450
+ metadata = _read_mirror_file_metadata(mirror_file_path)
451
+ max_radius = metadata.get("rmax")
452
+ total_area = metadata.get("total_surface_area")
453
+
454
+ # Calculate values if not available from file
455
+ if max_radius is None:
456
+ max_radius = np.sqrt(np.max(x_pos**2 + y_pos**2)) / 100.0
457
+
458
+ if total_area is None:
459
+ panel_area = 3 * np.sqrt(3) / 2 * (diameter / 200.0) ** 2
460
+ total_area = n_mirrors * panel_area
461
+
462
+ stats_text = (
463
+ f"Number of mirrors: {n_mirrors}\n"
464
+ f"Mirror diameter: {diameter:.1f} cm\n"
465
+ f"Max radius: {max_radius:.2f} m\n"
466
+ f"Total surface area: {total_area:.2f} $m^{2}$"
467
+ )
468
+
469
+ ax.text(
470
+ 0.02,
471
+ 0.98,
472
+ stats_text,
473
+ transform=ax.transAxes,
474
+ fontsize=11,
475
+ verticalalignment="top",
476
+ bbox=STATS_BOX_STYLE,
477
+ )
478
+
479
+
480
+ def _read_ring_segmentation_data(data_file_path):
481
+ """Read ring segmentation data from file."""
482
+ rings = []
483
+
484
+ with open(data_file_path, encoding="utf-8") as f:
485
+ for line in f:
486
+ if not line.startswith("#") and not line.startswith("%"):
487
+ if line.lower().startswith("ring"):
488
+ parts = line.split()
489
+ rings.append(
490
+ {
491
+ "nseg": int(parts[1].strip()),
492
+ "rmin": float(parts[2].strip()),
493
+ "rmax": float(parts[3].strip()),
494
+ "dphi": float(parts[4].strip()),
495
+ "phi0": float(parts[5].strip()),
496
+ }
497
+ )
498
+
499
+ return rings
500
+
501
+
502
+ def _plot_single_ring(ax, ring, cmap, color_index):
503
+ """Plot a single ring with its segments."""
504
+ rmin, rmax = ring["rmin"], ring["rmax"]
505
+ nseg, phi0 = ring["nseg"], ring["phi0"]
506
+ dphi = ring["dphi"]
507
+
508
+ # Angular gap between segments (in degrees) - represents the physical gaps
509
+ angular_gap = 0.3 # degrees
510
+
511
+ if nseg > 1:
512
+ dphi_rad = dphi * np.pi / 180
513
+ phi0_rad = phi0 * np.pi / 180
514
+ gap_rad = angular_gap * np.pi / 180
515
+
516
+ for i in range(nseg):
517
+ theta_i = i * dphi_rad + phi0_rad
518
+
519
+ # Fill segment with small gap on each side
520
+ n_theta = 100
521
+ theta_seg = np.linspace(theta_i + gap_rad, theta_i + dphi_rad - gap_rad, n_theta)
522
+
523
+ color_value = (color_index + i % 10) / 20.0 # Normalize to 0-1
524
+ color = cmap(color_value)
525
+ ax.fill_between(theta_seg, rmin, rmax, color=color, alpha=0.8)
526
+
527
+
528
+ def _add_ring_radius_label(ax, angle, radius, label_text):
529
+ """Add a radius label at the specified angle and radius."""
530
+ ax.text(
531
+ angle,
532
+ radius,
533
+ label_text,
534
+ ha="center",
535
+ va="center",
536
+ fontsize=9,
537
+ color="red",
538
+ weight="bold",
539
+ bbox={"boxstyle": "round,pad=0.3", "facecolor": "white", "alpha": 0.8},
540
+ )
541
+
542
+
543
+ def plot_mirror_ring_segmentation(data_file_path, telescope_model_name, parameter_type):
544
+ """
545
+ Plot mirror ring segmentation layout.
546
+
547
+ Parameters
548
+ ----------
549
+ data_file_path : Path or str
550
+ Path to the segmentation data file containing ring definitions with format:
551
+ ring <nseg> <rmin> <rmax> <dphi> <phi0>
552
+ telescope_model_name : str
553
+ Name of the telescope model (e.g., "LSTN-01", "MSTN-01")
554
+ parameter_type : str
555
+ Type of segmentation parameter (e.g., "primary_mirror_segmentation",
556
+ "secondary_mirror_segmentation")
557
+
558
+ Returns
559
+ -------
560
+ matplotlib.figure.Figure or None
561
+ The generated figure object, or None if no ring data found
562
+ """
563
+ logger.info(f"Plotting ring {parameter_type} for {telescope_model_name}")
564
+
565
+ rings = _read_ring_segmentation_data(data_file_path)
566
+
567
+ if not rings:
568
+ logger.warning(f"No ring data found in {data_file_path}")
569
+ return None
570
+
571
+ fig, ax = plt.subplots(subplot_kw={"projection": "polar"}, figsize=(10, 10))
572
+
573
+ cmap = mcolors.LinearSegmentedColormap.from_list("mirror_blue", ["#deebf7", "#3182bd"])
574
+
575
+ for i, ring in enumerate(rings):
576
+ _plot_single_ring(ax, ring, cmap, color_index=i * 10)
577
+
578
+ max_radius = max(ring["rmax"] for ring in rings)
579
+ label_padding = max_radius * 0.04
580
+ ax.set_ylim([0, max_radius + label_padding])
581
+ ax.set_yticklabels([])
582
+ ax.set_rgrids([])
583
+ ax.spines["polar"].set_visible(False)
584
+
585
+ label_angle = 30 * np.pi / 180
586
+
587
+ for ring in rings:
588
+ theta_full = np.linspace(0, 2 * np.pi, 360)
589
+ ax.plot(
590
+ theta_full,
591
+ np.repeat(ring["rmin"], len(theta_full)),
592
+ ":",
593
+ color="gray",
594
+ lw=0.8,
595
+ alpha=0.5,
596
+ )
597
+ ax.plot(
598
+ theta_full,
599
+ np.repeat(ring["rmax"], len(theta_full)),
600
+ ":",
601
+ color="gray",
602
+ lw=0.8,
603
+ alpha=0.5,
604
+ )
605
+
606
+ _add_ring_radius_label(ax, label_angle, ring["rmin"], f"{ring['rmin']:.3f}")
607
+ _add_ring_radius_label(ax, label_angle, ring["rmax"], f"{ring['rmax']:.3f}")
608
+
609
+ ax.text(
610
+ label_angle,
611
+ max_radius + label_padding * 2.5,
612
+ "[cm]",
613
+ ha="center",
614
+ va="center",
615
+ fontsize=10,
616
+ weight="bold",
617
+ color="red",
618
+ )
619
+
620
+ if len(rings) == 2:
621
+ stats_text = (
622
+ f"Inner ring segments: {rings[0]['nseg']}\nOuter ring segments: {rings[1]['nseg']}"
623
+ )
624
+ else:
625
+ stats_lines = [f"Ring {i + 1} segments: {ring['nseg']}" for i, ring in enumerate(rings)]
626
+ stats_text = "\n".join(stats_lines)
627
+
628
+ ax.text(
629
+ 0.02,
630
+ 0.98,
631
+ stats_text,
632
+ transform=ax.transAxes,
633
+ fontsize=11,
634
+ verticalalignment="top",
635
+ bbox=STATS_BOX_STYLE,
636
+ )
637
+
638
+ ax.text(
639
+ 0.02,
640
+ 0.02,
641
+ OBSERVER_TEXT,
642
+ transform=ax.transAxes,
643
+ fontsize=10,
644
+ ha="left",
645
+ va="bottom",
646
+ style="italic",
647
+ color="black",
648
+ )
649
+
650
+ plt.tight_layout()
651
+
652
+ return fig
653
+
654
+
655
+ def _parse_segment_id_line(line_stripped):
656
+ """Extract segment ID from a line if it contains segment ID information."""
657
+ try:
658
+ return int(line_stripped.split()[-1])
659
+ except (ValueError, IndexError):
660
+ return 0
661
+
662
+
663
+ def _is_skippable_line(line_stripped):
664
+ """Check if line should be skipped (empty or comment)."""
665
+ return not line_stripped or line_stripped.startswith(("#", "%"))
666
+
667
+
668
+ def _parse_shape_line(line_stripped, shape_segments, segment_ids, current_segment_id):
669
+ """Parse and append a single shape segmentation line."""
670
+ entries = line_stripped.split()
671
+
672
+ if any(line_stripped.lower().startswith(s) for s in ["hex", "yhex"]) and len(entries) >= 5:
673
+ shape_segments.append(
674
+ {
675
+ "shape": entries[0].lower(),
676
+ "x": float(entries[2]),
677
+ "y": float(entries[3]),
678
+ "diameter": float(entries[4]),
679
+ "rotation": float(entries[5]) if len(entries) > 5 else 0.0,
680
+ }
681
+ )
682
+ segment_ids.append(current_segment_id if current_segment_id > 0 else len(shape_segments))
683
+
684
+
685
+ def _read_shape_segmentation_file(data_file_path):
686
+ """
687
+ Read shape segmentation file.
688
+
689
+ Parameters
690
+ ----------
691
+ data_file_path : Path
692
+ Path to segmentation file
693
+
694
+ Returns
695
+ -------
696
+ tuple
697
+ (shape_segments, segment_ids)
698
+ """
699
+ shape_segments, segment_ids = [], []
700
+ current_segment_id = 0
701
+
702
+ with open(data_file_path, encoding="utf-8") as f:
703
+ for line in f:
704
+ line_stripped = line.strip()
705
+
706
+ if "segment id" in line_stripped.lower():
707
+ current_segment_id = _parse_segment_id_line(line_stripped)
708
+ elif not _is_skippable_line(line_stripped):
709
+ _parse_shape_line(line_stripped, shape_segments, segment_ids, current_segment_id)
710
+
711
+ return shape_segments, segment_ids
712
+
713
+
714
+ def _add_segment_label(ax, x, y, label):
715
+ """Add a label at the specified position."""
716
+ ax.text(x, y, str(label), **LABEL_STYLE)
717
+
718
+
719
+ def _create_shape_patches(ax, shape_segments, segment_ids):
720
+ """
721
+ Create patches for shape segments (hexagons).
722
+
723
+ Parameters
724
+ ----------
725
+ shape_segments : list
726
+ List of shape segment dictionaries
727
+ segment_ids : list
728
+ List of segment IDs
729
+ ax : matplotlib.axes.Axes
730
+ Axes to add text labels to
731
+
732
+ Returns
733
+ -------
734
+ tuple
735
+ (patches, maximum_radius)
736
+ """
737
+ patches, maximum_radius = [], 0
738
+
739
+ for i_seg, seg in enumerate(shape_segments):
740
+ x, y, diam, rot = seg["x"], seg["y"], seg["diameter"], seg["rotation"]
741
+ maximum_radius = max(maximum_radius, abs(x) + diam / 2, abs(y) + diam / 2)
742
+
743
+ patch = mpatches.RegularPolygon(
744
+ (x, y),
745
+ numVertices=6,
746
+ radius=diam / np.sqrt(3),
747
+ orientation=np.deg2rad(rot),
748
+ **PATCH_STYLE,
749
+ )
750
+
751
+ patches.append(patch)
752
+ label = segment_ids[i_seg] if i_seg < len(segment_ids) else i_seg + 1
753
+ _add_segment_label(ax, x, y, label)
754
+
755
+ return patches, maximum_radius
756
+
757
+
758
+ def plot_mirror_shape_segmentation(data_file_path, telescope_model_name, parameter_type):
759
+ """
760
+ Plot mirror shape segmentation layout.
761
+
762
+ Parameters
763
+ ----------
764
+ data_file_path : Path or str
765
+ Path to the segmentation data file containing explicit shape definitions
766
+ (hex, yhex) with positions, diameters, and rotations
767
+ telescope_model_name : str
768
+ Name of the telescope model (e.g., "LSTN-01", "MSTN-design")
769
+ parameter_type : str
770
+ Type of segmentation parameter (e.g., "primary_mirror_segmentation",
771
+ "secondary_mirror_segmentation")
772
+
773
+ Returns
774
+ -------
775
+ matplotlib.figure.Figure
776
+ The generated figure object
777
+ """
778
+ logger.info(f"Plotting shape {parameter_type} for {telescope_model_name}")
779
+
780
+ shape_segments, segment_ids = _read_shape_segmentation_file(data_file_path)
781
+
782
+ for seg in shape_segments:
783
+ seg["x"], seg["y"] = _rotate_coordinates_clockwise_90(seg["x"], seg["y"])
784
+ seg["rotation"] = seg["rotation"] - 90
785
+
786
+ fig, ax = plt.subplots(figsize=(10, 10))
787
+
788
+ # Create patches for shape segments
789
+ all_patches, maximum_radius = _create_shape_patches(ax, shape_segments, segment_ids)
790
+
791
+ collection = PatchCollection(all_patches, match_original=True)
792
+ ax.add_collection(collection)
793
+
794
+ ax.set_aspect("equal")
795
+ padding = maximum_radius * 0.1 if maximum_radius > 0 else 100
796
+ ax.set_xlim(-maximum_radius - padding, maximum_radius + padding)
797
+ ax.set_ylim(-maximum_radius - padding, maximum_radius + padding)
798
+
799
+ plt.xlabel("X position [cm]", fontsize=14)
800
+ plt.ylabel("Y position [cm]", fontsize=14)
801
+ plt.grid(True, alpha=0.3)
802
+ plt.tick_params(axis="both", which="major", labelsize=12)
803
+
804
+ ax.text(
805
+ 0.02,
806
+ 0.02,
807
+ OBSERVER_TEXT,
808
+ transform=ax.transAxes,
809
+ fontsize=10,
810
+ ha="left",
811
+ va="bottom",
812
+ style="italic",
813
+ color="black",
814
+ )
815
+
816
+ total_segments = len(shape_segments)
817
+ if segment_ids and total_segments > 0:
818
+ stats_text = f"Number of segments: {len(set(segment_ids))}"
819
+ elif total_segments > 0:
820
+ stats_text = f"Number of segments: {total_segments}"
821
+ else:
822
+ stats_text = "No segment data"
823
+
824
+ ax.text(
825
+ 0.02,
826
+ 0.98,
827
+ stats_text,
828
+ transform=ax.transAxes,
829
+ fontsize=11,
830
+ verticalalignment="top",
831
+ bbox=STATS_BOX_STYLE,
832
+ )
833
+
834
+ return fig