gammasimtools 0.20.0__py3-none-any.whl → 0.21.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 (25) hide show
  1. {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/METADATA +1 -1
  2. {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/RECORD +24 -23
  3. {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/entry_points.txt +1 -1
  4. simtools/_version.py +2 -2
  5. simtools/applications/db_generate_compound_indexes.py +1 -1
  6. simtools/applications/derive_psf_parameters.py +58 -39
  7. simtools/applications/generate_corsika_histograms.py +7 -184
  8. simtools/applications/maintain_simulation_model_add_production.py +105 -0
  9. simtools/applications/plot_simtel_events.py +2 -228
  10. simtools/applications/print_version.py +8 -7
  11. simtools/corsika/corsika_histograms.py +81 -0
  12. simtools/db/db_handler.py +45 -11
  13. simtools/db/db_model_upload.py +40 -14
  14. simtools/model/model_repository.py +118 -63
  15. simtools/ray_tracing/psf_parameter_optimisation.py +999 -565
  16. simtools/simtel/simtel_config_writer.py +1 -1
  17. simtools/simulator.py +1 -4
  18. simtools/version.py +89 -0
  19. simtools/{corsika/corsika_histograms_visualize.py → visualization/plot_corsika_histograms.py} +109 -0
  20. simtools/visualization/plot_psf.py +673 -0
  21. simtools/visualization/plot_simtel_events.py +284 -87
  22. simtools/applications/maintain_simulation_model_add_production_table.py +0 -71
  23. {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/WHEEL +0 -0
  24. {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/licenses/LICENSE +0 -0
  25. {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,673 @@
1
+ """
2
+ Optical PSF plotting functions for parameter optimization visualization.
3
+
4
+ This module provides plotting functionality for PSF parameter optimization,
5
+ including parameter comparison plots, convergence plots, and D80 vs off-axis plots.
6
+ """
7
+
8
+ import logging
9
+
10
+ import astropy.units as u
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+ from matplotlib.backends.backend_pdf import PdfPages
14
+
15
+ from simtools.ray_tracing.ray_tracing import RayTracing
16
+ from simtools.visualization import visualize
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Constants
21
+ RADIUS_CM = "Radius [cm]"
22
+ CUMULATIVE_PSF = "Cumulative PSF"
23
+ D80_CM_LABEL = "D80 (cm)"
24
+ MAX_OFFSET_DEFAULT = 4.5 # Maximum off-axis angle in degrees
25
+ OFFSET_STEPS_DEFAULT = 0.1 # Step size for off-axis angle sampling
26
+
27
+
28
+ def get_significance_label(p_value):
29
+ """Get significance label for p-value."""
30
+ if p_value > 0.05: # null hypothesis not rejected at the 95% level
31
+ return "GOOD"
32
+ if p_value > 0.01: # null hypothesis rejected at 95% but not at 99% level
33
+ return "FAIR"
34
+ return "POOR" # null hypothesis rejected at 99% level
35
+
36
+
37
+ def _format_metric_text(d80, metric, p_value=None, use_ks_statistic=False, second_metric=None):
38
+ """
39
+ Format metric text for display in plots.
40
+
41
+ Parameters
42
+ ----------
43
+ d80 : float
44
+ D80 value
45
+ metric : float
46
+ Primary metric value (RMSD or KS statistic)
47
+ p_value : float, optional
48
+ P-value from KS test
49
+ use_ks_statistic : bool
50
+ If True, metric is KS statistic; if False, metric is RMSD
51
+ second_metric : float, optional
52
+ Second metric value to display alongside the primary metric
53
+
54
+ Returns
55
+ -------
56
+ str
57
+ Formatted metric text
58
+ """
59
+ d80_text = f"D80 = {d80:.5f} cm"
60
+
61
+ # Create metric text based on the optimization method
62
+ if second_metric is not None:
63
+ # Special case: show both RMSD and KS statistic (for final best plot)
64
+ metric_text = f"RMSD = {metric:.4f}" # metric is RMSD in this case
65
+ metric_text += f"\nKS statistic = {second_metric:.4f}" # second_metric is KS statistic
66
+ if p_value is not None:
67
+ metric_text += f"\np-value = {p_value:.4f}"
68
+ elif use_ks_statistic:
69
+ metric_text = f"KS stat = {metric:.6f}"
70
+ if p_value is not None:
71
+ significance = get_significance_label(p_value)
72
+ metric_text += f"\np-value = {p_value:.6f} ({significance})"
73
+ else:
74
+ metric_text = f"RMSD = {metric:.4f}"
75
+
76
+ return f"{d80_text}\n{metric_text}"
77
+
78
+
79
+ def _create_base_plot_figure(data_to_plot, simulated_data=None):
80
+ """
81
+ Create base figure for PSF parameter plots.
82
+
83
+ Parameters
84
+ ----------
85
+ data_to_plot : dict
86
+ Data dictionary for plotting
87
+ simulated_data : array, optional
88
+ Simulated data to add to the plot
89
+
90
+ Returns
91
+ -------
92
+ tuple
93
+ (fig, ax) - figure and axis objects
94
+ """
95
+ plot_data = data_to_plot.copy()
96
+
97
+ if simulated_data is not None:
98
+ plot_data["simulated"] = simulated_data
99
+
100
+ fig = visualize.plot_1d(
101
+ plot_data,
102
+ plot_difference=True,
103
+ no_markers=True,
104
+ )
105
+ ax = fig.get_axes()[0]
106
+ ax.set_ylim(0, 1.05)
107
+ ax.set_ylabel(CUMULATIVE_PSF)
108
+ return fig, ax
109
+
110
+
111
+ def _build_parameter_title(pars, is_best):
112
+ """Build parameter title string for plots."""
113
+ title_prefix = "* " if is_best else ""
114
+ return (
115
+ f"{title_prefix}reflection = "
116
+ f"{pars['mirror_reflection_random_angle'][0]:.5f}, "
117
+ f"{pars['mirror_reflection_random_angle'][1]:.5f}, "
118
+ f"{pars['mirror_reflection_random_angle'][2]:.5f}\n"
119
+ f"align_vertical = {pars['mirror_align_random_vertical'][0]:.5f}, "
120
+ f"{pars['mirror_align_random_vertical'][1]:.5f}, "
121
+ f"{pars['mirror_align_random_vertical'][2]:.5f}, "
122
+ f"{pars['mirror_align_random_vertical'][3]:.5f}\n"
123
+ f"align_horizontal = {pars['mirror_align_random_horizontal'][0]:.5f}, "
124
+ f"{pars['mirror_align_random_horizontal'][1]:.5f}, "
125
+ f"{pars['mirror_align_random_horizontal'][2]:.5f}, "
126
+ f"{pars['mirror_align_random_horizontal'][3]:.5f}"
127
+ )
128
+
129
+
130
+ def _add_metric_text_box(ax, metrics_text, is_best):
131
+ """Add metric text box to plot."""
132
+ d80_color = "red" if is_best else "black"
133
+ d80_weight = "bold" if is_best else "normal"
134
+
135
+ ax.text(
136
+ 0.5,
137
+ 0.3,
138
+ metrics_text,
139
+ verticalalignment="center",
140
+ horizontalalignment="left",
141
+ transform=ax.transAxes,
142
+ color=d80_color,
143
+ weight=d80_weight,
144
+ bbox={"boxstyle": "round,pad=0.3", "facecolor": "yellow", "alpha": 0.7}
145
+ if is_best
146
+ else None,
147
+ )
148
+
149
+
150
+ def _add_plot_annotations(
151
+ ax, fig, pars, d80, metric, is_best, p_value=None, use_ks_statistic=False, second_metric=None
152
+ ):
153
+ """
154
+ Add title, text annotations, and best parameter indicators to plot.
155
+
156
+ Parameters
157
+ ----------
158
+ ax : matplotlib.axes.Axes
159
+ The plot axes
160
+ fig : matplotlib.figure.Figure
161
+ The plot figure
162
+ pars : dict
163
+ Parameter set dictionary
164
+ d80 : float
165
+ D80 value
166
+ metric : float
167
+ Primary metric value
168
+ is_best : bool
169
+ Whether this is the best parameter set
170
+ p_value : float, optional
171
+ P-value from KS test
172
+ use_ks_statistic : bool
173
+ If True, metric is KS statistic; if False, metric is RMSD
174
+ second_metric : float, optional
175
+ Second metric value to display
176
+ """
177
+ title = _build_parameter_title(pars, is_best)
178
+ ax.set_title(title)
179
+
180
+ metrics_text = _format_metric_text(d80, metric, p_value, use_ks_statistic, second_metric)
181
+ _add_metric_text_box(ax, metrics_text, is_best)
182
+
183
+ if is_best:
184
+ fig.text(
185
+ 0.02,
186
+ 0.02,
187
+ "* Best parameter set (lowest RMSD)",
188
+ fontsize=8,
189
+ style="italic",
190
+ color="red",
191
+ )
192
+
193
+
194
+ def create_psf_parameter_plot(
195
+ data_to_plot,
196
+ pars,
197
+ d80,
198
+ metric,
199
+ is_best,
200
+ pdf_pages,
201
+ p_value=None,
202
+ use_ks_statistic=False,
203
+ second_metric=None,
204
+ ):
205
+ """
206
+ Create a plot for PSF simulation results.
207
+
208
+ Parameters
209
+ ----------
210
+ data_to_plot : dict
211
+ Data dictionary for plotting.
212
+ pars : dict
213
+ Parameter set dictionary.
214
+ d80 : float
215
+ D80 value.
216
+ metric : float
217
+ RMSD value (if use_ks_statistic=False) or KS statistic (if use_ks_statistic=True).
218
+ is_best : bool
219
+ Whether this is the best parameter set.
220
+ pdf_pages : PdfPages
221
+ PDF pages object for saving plots.
222
+ p_value : float, optional
223
+ P-value from KS test (only used when use_ks_statistic=True).
224
+ use_ks_statistic : bool, optional
225
+ If True, metric is KS statistic; if False, metric is RMSD.
226
+ second_metric : float, optional
227
+ Second metric value to display alongside the primary metric (for final best plot).
228
+ """
229
+ fig, ax = _create_base_plot_figure(data_to_plot)
230
+
231
+ _add_plot_annotations(
232
+ ax, fig, pars, d80, metric, is_best, p_value, use_ks_statistic, second_metric
233
+ )
234
+
235
+ pdf_pages.savefig(fig, bbox_inches="tight")
236
+ plt.clf()
237
+
238
+
239
+ def create_detailed_parameter_plot(
240
+ pars, ks_statistic, d80, simulated_data, data_to_plot, is_best, pdf_pages, p_value=None
241
+ ):
242
+ """
243
+ Create a detailed plot for a parameter set showing all parameter values.
244
+
245
+ Parameters
246
+ ----------
247
+ pars : dict
248
+ Parameter set dictionary
249
+ ks_statistic : float
250
+ KS statistic value for this parameter set
251
+ d80 : float
252
+ D80 value for this parameter set
253
+ simulated_data : array
254
+ Simulated data for plotting
255
+ data_to_plot : dict
256
+ Data dictionary for plotting
257
+ is_best : bool
258
+ Whether this is the best parameter set
259
+ pdf_pages : PdfPages
260
+ PDF pages object to save the plot
261
+ p_value : float, optional
262
+ P-value from KS test for statistical significance
263
+ """
264
+ # Check if we have valid simulated data for plotting
265
+ if simulated_data is None:
266
+ logger.warning(
267
+ "No simulated data available for plotting this parameter set, skipping plot creation"
268
+ )
269
+ return
270
+
271
+ try:
272
+ fig, ax = _create_base_plot_figure(data_to_plot, simulated_data)
273
+ except (ValueError, RuntimeError, KeyError, TypeError) as e:
274
+ logger.error(f"Failed to create plot for parameters: {e}")
275
+ return
276
+
277
+ _add_plot_annotations(
278
+ ax,
279
+ fig,
280
+ pars,
281
+ d80,
282
+ ks_statistic,
283
+ is_best,
284
+ p_value,
285
+ use_ks_statistic=True,
286
+ second_metric=None,
287
+ )
288
+
289
+ pdf_pages.savefig(fig, bbox_inches="tight")
290
+ plt.clf()
291
+
292
+
293
+ def create_parameter_progression_plots(results, best_pars, data_to_plot, pdf_pages):
294
+ """
295
+ Create plots for all parameter sets showing optimization progression.
296
+
297
+ Parameters
298
+ ----------
299
+ results : list
300
+ List of (pars, ks_statistic, p_value, d80, simulated_data) tuples
301
+ best_pars : dict
302
+ Best parameter set for highlighting
303
+ data_to_plot : dict
304
+ Data dictionary for plotting
305
+ pdf_pages : PdfPages
306
+ PDF pages object to save plots
307
+ """
308
+ logger.info("Creating plots for all parameter sets...")
309
+
310
+ for i, (pars, ks_statistic, p_value, d80, simulated_data) in enumerate(results):
311
+ if simulated_data is None:
312
+ logger.warning(f"No simulated data for iteration {i}, skipping plot")
313
+ continue
314
+
315
+ is_best = pars is best_pars
316
+ logger.info(f"Creating plot {i + 1}/{len(results)}{' (BEST)' if is_best else ''}")
317
+
318
+ create_detailed_parameter_plot(
319
+ pars, ks_statistic, d80, simulated_data, data_to_plot, is_best, pdf_pages, p_value
320
+ )
321
+
322
+
323
+ def create_gradient_descent_convergence_plot(
324
+ gd_results, threshold, output_file, use_ks_statistic=False
325
+ ):
326
+ """
327
+ Create convergence plot showing optimization metric and D80 progression during gradient descent.
328
+
329
+ Parameters
330
+ ----------
331
+ gd_results : list
332
+ List of (params, metric, p_value, d80, simulated_data) tuples from gradient descent
333
+ threshold : float
334
+ Optimization metric threshold used for convergence
335
+ output_file : Path
336
+ Output file path for saving the plot
337
+ use_ks_statistic : bool
338
+ Whether to use KS statistic or RMSD labels and titles
339
+ """
340
+ logger.info("Creating gradient descent convergence plot...")
341
+
342
+ # Check if results include p-values (for KS statistic mode)
343
+ has_p_values = len(gd_results[0]) >= 4 and gd_results[0][2] is not None
344
+
345
+ if has_p_values and use_ks_statistic:
346
+ _, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 12), tight_layout=True)
347
+ else:
348
+ _, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), tight_layout=True)
349
+
350
+ iterations = list(range(len(gd_results)))
351
+ metrics = [r[1] for r in gd_results]
352
+ d80s = [r[3] for r in gd_results]
353
+
354
+ metric_name = "KS Statistic" if use_ks_statistic else "RMSD"
355
+
356
+ # Plot optimization metric progression
357
+ ax1.plot(iterations, metrics, "b.-", linewidth=2, markersize=6)
358
+ ax1.axhline(y=threshold, color="r", linestyle="--", label=f"Threshold: {threshold}")
359
+ ax1.set_xlabel("Iteration")
360
+ ax1.set_ylabel(metric_name)
361
+ ax1.set_title(f"Gradient Descent Convergence - {metric_name}")
362
+ ax1.grid(True, alpha=0.3)
363
+ ax1.legend()
364
+
365
+ # Plot D80 progression
366
+ ax2.plot(iterations, d80s, "g.-", linewidth=2, markersize=6)
367
+ ax2.set_xlabel("Iteration")
368
+ ax2.set_ylabel(D80_CM_LABEL)
369
+ ax2.set_title("Gradient Descent Convergence - D80")
370
+ ax2.grid(True, alpha=0.3)
371
+
372
+ # Plot p-value progression if available and using KS statistic
373
+ if has_p_values and use_ks_statistic:
374
+ p_values = [r[2] for r in gd_results if r[2] is not None]
375
+ p_iterations = [i for i, r in enumerate(gd_results) if r[2] is not None]
376
+
377
+ ax3.plot(p_iterations, p_values, "m.-", linewidth=2, markersize=6)
378
+ ax3.axhline(
379
+ y=0.05, color="orange", linestyle="--", alpha=0.7, label="p = 0.05 (significance)"
380
+ )
381
+ ax3.axhline(
382
+ y=0.01, color="r", linestyle="--", alpha=0.7, label="p = 0.01 (high significance)"
383
+ )
384
+ ax3.set_xlabel("Iteration")
385
+ ax3.set_ylabel("p-value")
386
+ ax3.set_title("Gradient Descent Convergence - Statistical Significance")
387
+ ax3.set_yscale("log") # Log scale for p-values
388
+ ax3.grid(True, alpha=0.3)
389
+ ax3.legend()
390
+
391
+ plt.savefig(output_file, bbox_inches="tight")
392
+ plt.close()
393
+
394
+ logger.info(f"Convergence plot saved to {output_file}")
395
+
396
+
397
+ def create_monte_carlo_uncertainty_plot(mc_results, output_file, use_ks_statistic=False):
398
+ """
399
+ Create uncertainty analysis plots showing optimization metric and p-value distributions.
400
+
401
+ Parameters
402
+ ----------
403
+ mc_results : tuple
404
+ Results from Monte Carlo analysis: (mean_metric, std_metric, metric_values,
405
+ mean_p, std_p, p_values, mean_d80, std_d80, d80_values)
406
+ output_file : Path
407
+ Output file path for saving the plot
408
+ use_ks_statistic : bool, optional
409
+ Whether KS statistic mode is being used (affects filename suffix)
410
+ """
411
+ (
412
+ mean_metric,
413
+ std_metric,
414
+ metric_values,
415
+ mean_p_value,
416
+ _, # std_p_value (unused)
417
+ p_values,
418
+ mean_d80,
419
+ std_d80,
420
+ d80_values,
421
+ ) = mc_results
422
+
423
+ logger.info("Creating Monte Carlo uncertainty analysis plot...")
424
+
425
+ # Check if we have valid p-values to determine if this is KS statistic or RMSD mode
426
+ valid_p_values = [p for p in p_values if p is not None] if p_values else []
427
+ is_ks_mode = len(valid_p_values) > 0
428
+
429
+ # Create subplot layout based on mode
430
+ if is_ks_mode:
431
+ # KS mode: 2x2 layout with all 4 plots
432
+ _, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 10), tight_layout=True)
433
+ else:
434
+ # RMSD mode: 1x2 layout with only metric and D80 plots
435
+ _, (ax1, ax3) = plt.subplots(1, 2, figsize=(12, 5), tight_layout=True)
436
+
437
+ # Metric histogram (KS statistic or RMSD)
438
+ metric_name = "KS Statistic" if is_ks_mode else "RMSD"
439
+ ax1.hist(metric_values, bins=20, alpha=0.7, color="blue", edgecolor="black")
440
+ ax1.axvline(
441
+ mean_metric, color="red", linestyle="--", linewidth=2, label=f"Mean: {mean_metric:.6f}"
442
+ )
443
+ ax1.axvline(
444
+ mean_metric - std_metric,
445
+ color="orange",
446
+ linestyle=":",
447
+ alpha=0.7,
448
+ label=f"$\\sigma$: {std_metric:.6f}",
449
+ )
450
+ ax1.axvline(mean_metric + std_metric, color="orange", linestyle=":", alpha=0.7)
451
+ ax1.set_xlabel(metric_name)
452
+ ax1.set_ylabel("Counts")
453
+ ax1.set_title(f"{metric_name} Distribution")
454
+ ax1.legend()
455
+ ax1.grid(True, alpha=0.3)
456
+
457
+ # p-value histogram (only for KS statistic mode)
458
+ if is_ks_mode:
459
+ ax2.hist(valid_p_values, bins=20, alpha=0.7, color="magenta", edgecolor="black")
460
+ if mean_p_value is not None:
461
+ ax2.axvline(
462
+ mean_p_value,
463
+ color="red",
464
+ linestyle="--",
465
+ linewidth=2,
466
+ label=f"Mean: {mean_p_value:.6f}",
467
+ )
468
+ ax2.axvline(0.05, color="orange", linestyle="--", alpha=0.7, label="p = 0.05")
469
+ ax2.axvline(0.01, color="red", linestyle="--", alpha=0.7, label="p = 0.01")
470
+ ax2.set_xlabel("p-value")
471
+ ax2.set_ylabel("Counts")
472
+ ax2.set_title("p-value Distribution")
473
+ ax2.legend()
474
+ ax2.grid(True, alpha=0.3)
475
+
476
+ # D80 histogram
477
+ ax3.hist(d80_values, bins=20, alpha=0.7, color="green", edgecolor="black")
478
+ ax3.axvline(
479
+ mean_d80, color="red", linestyle="--", linewidth=2, label=f"Mean: {mean_d80:.4f} cm"
480
+ )
481
+ ax3.axvline(
482
+ mean_d80 - std_d80,
483
+ color="orange",
484
+ linestyle=":",
485
+ alpha=0.7,
486
+ label=f"$\\sigma$: {std_d80:.4f} cm",
487
+ )
488
+ ax3.axvline(mean_d80 + std_d80, color="orange", linestyle=":", alpha=0.7)
489
+ ax3.set_xlabel(D80_CM_LABEL)
490
+ ax3.set_ylabel("Counts")
491
+ ax3.set_title("D80 Distribution")
492
+ ax3.legend()
493
+ ax3.grid(True, alpha=0.3)
494
+
495
+ # Scatter plot: Metric vs p-value (only for KS statistic mode)
496
+ if is_ks_mode:
497
+ ax4.scatter(metric_values, valid_p_values, alpha=0.6, color="purple")
498
+ ax4.axhline(y=0.05, color="orange", linestyle="--", alpha=0.7, label="p = 0.05")
499
+ ax4.axhline(y=0.01, color="red", linestyle="--", alpha=0.7, label="p = 0.01")
500
+ ax4.set_xlabel(metric_name)
501
+ ax4.set_ylabel("p-value")
502
+ ax4.set_title(f"{metric_name} vs p-value Correlation")
503
+ ax4.set_yscale("log")
504
+ ax4.legend()
505
+ ax4.grid(True, alpha=0.3)
506
+
507
+ # Save plot in both PDF and PNG formats with appropriate suffix
508
+ suffix = "_ks" if use_ks_statistic else "_rmsd"
509
+
510
+ # Generate base filename without extension
511
+ base_path = output_file.with_suffix("")
512
+ base_name = str(base_path)
513
+
514
+ # Add suffix and save in both formats
515
+ pdf_file = f"{base_name}{suffix}.pdf"
516
+ png_file = f"{base_name}{suffix}.png"
517
+
518
+ plt.savefig(pdf_file, bbox_inches="tight")
519
+ plt.savefig(png_file, bbox_inches="tight", dpi=150)
520
+ plt.close()
521
+
522
+ logger.info(f"Monte Carlo uncertainty plot saved to {pdf_file}")
523
+ logger.info(f"Monte Carlo uncertainty plot saved to {png_file}")
524
+
525
+
526
+ def create_d80_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, output_dir):
527
+ """
528
+ Create D80 vs off-axis angle plot using the best parameters.
529
+
530
+ Parameters
531
+ ----------
532
+ tel_model : TelescopeModel
533
+ Telescope model object.
534
+ site_model : SiteModel
535
+ Site model object.
536
+ args_dict : dict
537
+ Dictionary containing parsed command-line arguments.
538
+ best_pars : dict
539
+ Best parameter set.
540
+ output_dir : Path
541
+ Output directory for saving plots.
542
+ """
543
+ logger.info("Creating D80 vs off-axis angle plot with best parameters...")
544
+
545
+ # Apply best parameters to telescope model
546
+ tel_model.change_multiple_parameters(**best_pars)
547
+
548
+ # Create off-axis angle array
549
+ max_offset = args_dict.get("max_offset", MAX_OFFSET_DEFAULT)
550
+ offset_steps = args_dict.get("offset_steps", OFFSET_STEPS_DEFAULT)
551
+ off_axis_angles = np.linspace(
552
+ 0,
553
+ max_offset,
554
+ int(max_offset / offset_steps) + 1,
555
+ )
556
+
557
+ ray = RayTracing(
558
+ telescope_model=tel_model,
559
+ site_model=site_model,
560
+ simtel_path=args_dict["simtel_path"],
561
+ zenith_angle=args_dict["zenith"] * u.deg,
562
+ source_distance=args_dict["src_distance"] * u.km,
563
+ off_axis_angle=off_axis_angles * u.deg,
564
+ )
565
+
566
+ logger.info(f"Running ray tracing for {len(off_axis_angles)} off-axis angles...")
567
+ ray.simulate(test=args_dict.get("test", False), force=True)
568
+ ray.analyze(force=True)
569
+
570
+ for key in ["d80_cm", "d80_deg"]:
571
+ plt.figure(figsize=(10, 6), tight_layout=True)
572
+
573
+ ray.plot(key, marker="o", linestyle="-", color="blue", linewidth=2, markersize=6)
574
+
575
+ parameters_text = (
576
+ f"Best Parameters: \n"
577
+ f"reflection=["
578
+ f"{', '.join(f'{x:.4f}' for x in best_pars['mirror_reflection_random_angle'])}],\n"
579
+ f"align_horizontal=["
580
+ f"{', '.join(f'{x:.4f}' for x in best_pars['mirror_align_random_horizontal'])}]\n"
581
+ f"align_vertical=["
582
+ f"{', '.join(f'{x:.4f}' for x in best_pars['mirror_align_random_vertical'])}]\n"
583
+ )
584
+ plt.title(parameters_text)
585
+ plt.xlabel("Off-axis Angle (degrees)")
586
+ plt.ylabel(D80_CM_LABEL if key == "d80_cm" else "D80 (degrees)")
587
+ plt.ylim(bottom=0)
588
+ plt.xticks(rotation=45)
589
+ plt.xlim(0, max_offset)
590
+ plt.grid(True, alpha=0.3)
591
+
592
+ plot_file_name = f"{tel_model.name}_best_params_{key}.png"
593
+ plot_file = output_dir.joinpath(plot_file_name)
594
+ visualize.save_figure(
595
+ plt, plot_file, figure_format=["png"], log_title=f"D80 vs off-axis ({key})"
596
+ )
597
+
598
+ plt.close("all")
599
+
600
+
601
+ def setup_pdf_plotting(args_dict, output_dir, tel_model_name):
602
+ """
603
+ Set up PDF plotting for gradient descent optimization if requested.
604
+
605
+ Parameters
606
+ ----------
607
+ args_dict : dict
608
+ Dictionary containing command-line arguments with plot_all flag.
609
+ output_dir : Path
610
+ Directory where the PDF file will be saved.
611
+ tel_model_name : str
612
+ Name of the telescope model for filename generation.
613
+
614
+ Returns
615
+ -------
616
+ PdfPages or None
617
+ PdfPages object for saving plots if plotting is requested, None otherwise.
618
+
619
+ Notes
620
+ -----
621
+ Creates a PDF file for saving cumulative PSF plots during gradient descent
622
+ optimization. Returns None if plotting is not requested via the plot_all flag.
623
+ """
624
+ if not args_dict.get("plot_all", False):
625
+ return None
626
+ pdf_filename = output_dir / f"psf_gradient_descent_plots_{tel_model_name}.pdf"
627
+ pdf_pages = PdfPages(pdf_filename)
628
+ logger.info(f"Creating cumulative PSF plots for each iteration (saving to {pdf_filename})")
629
+ return pdf_pages
630
+
631
+
632
+ def create_optimization_plots(args_dict, gd_results, tel_model, data_to_plot, output_dir):
633
+ """
634
+ Create optimization plots for all iterations if requested.
635
+
636
+ Parameters
637
+ ----------
638
+ args_dict : dict
639
+ Dictionary containing command-line arguments with save_plots flag.
640
+ gd_results : list
641
+ List of (params, rmsd, _, d80, _) tuples from gradient descent optimization.
642
+ tel_model : TelescopeModel
643
+ Telescope model object for naming files.
644
+ data_to_plot : dict
645
+ Dictionary containing measured PSF data.
646
+ output_dir : Path
647
+ Directory where the PDF file will be saved.
648
+
649
+ Notes
650
+ -----
651
+ Creates a PDF file with plots for optimization iterations. Only creates plots
652
+ every 5 iterations plus the final iteration to avoid excessively large files.
653
+ Returns early if save_plots flag is not set.
654
+ """
655
+ if not args_dict.get("save_plots", False):
656
+ return
657
+
658
+ pdf_filename = output_dir.joinpath(f"psf_optimization_results_{tel_model.name}.pdf")
659
+ pdf_pages = PdfPages(pdf_filename)
660
+ logger.info(f"Creating PSF plots for each optimization iteration (saving to {pdf_filename})")
661
+
662
+ for i, (params, rmsd, _, d80, _) in enumerate(gd_results):
663
+ if i % 5 == 0 or i == len(gd_results) - 1:
664
+ create_psf_parameter_plot(
665
+ data_to_plot,
666
+ params,
667
+ d80,
668
+ rmsd,
669
+ is_best=(i == len(gd_results) - 1),
670
+ pdf_pages=pdf_pages,
671
+ use_ks_statistic=False,
672
+ )
673
+ pdf_pages.close()