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
@@ -4,7 +4,6 @@ PSF parameter optimisation and fitting routines for mirror alignment and reflect
4
4
  This module provides functions for loading PSF data, generating random parameter sets,
5
5
  running PSF simulations, calculating RMSD, and finding the best-fit parameters for a given
6
6
  telescope model.
7
-
8
7
  PSF (Point Spread Function) describes how a point source of light is spread out by the
9
8
  optical system, and RMSD (Root Mean Squared Deviation) is used as the optimization metric
10
9
  to quantify the difference between measured and simulated PSF curves.
@@ -14,206 +13,87 @@ import logging
14
13
  from collections import OrderedDict
15
14
 
16
15
  import astropy.units as u
17
- import matplotlib.pyplot as plt
18
16
  import numpy as np
19
- from matplotlib.backends.backend_pdf import PdfPages
17
+ from astropy.table import Table
18
+ from scipy import stats
20
19
 
21
20
  from simtools.data_model import model_data_writer as writer
22
- from simtools.model import model_utils
23
21
  from simtools.ray_tracing.ray_tracing import RayTracing
24
22
  from simtools.utils import general as gen
25
- from simtools.visualization import visualize
23
+ from simtools.visualization import plot_psf
26
24
 
27
25
  logger = logging.getLogger(__name__)
28
26
 
29
27
  # Constants
30
- RADIUS_CM = "Radius [cm]"
28
+ RADIUS = "Radius"
31
29
  CUMULATIVE_PSF = "Cumulative PSF"
32
-
33
- MRRA_RANGE_DEFAULT = 0.004 # Mirror reflection random angle range
34
- MRF_RANGE_DEFAULT = 0.1 # Mirror reflection fraction range
35
- MRRA2_RANGE_DEFAULT = 0.03 # Second mirror reflection random angle range
36
- MAR_RANGE_DEFAULT = 0.005 # Mirror alignment random range
37
- MAX_OFFSET_DEFAULT = 4.5 # Maximum off-axis angle in degrees
38
- OFFSET_STEPS_DEFAULT = 0.1 # Step size for off-axis angle sampling
30
+ KS_STATISTIC_NAME = "KS statistic"
39
31
 
40
32
 
41
- def load_psf_data(data_file):
42
- """
43
- Load data from a text file containing cumulative PSF measurements.
33
+ def _create_log_header_and_format_value(title, tel_model, additional_info=None, value=None):
34
+ """Create log header and format parameter values."""
35
+ if value is not None: # Format value mode
36
+ if isinstance(value, list):
37
+ return "[" + ", ".join([f"{v:.6f}" for v in value]) + "]"
38
+ if isinstance(value, int | float):
39
+ return f"{value:.6f}"
40
+ return str(value)
44
41
 
45
- Parameters
46
- ----------
47
- data_file : str
48
- Name of the data file with the measured cumulative PSF.
49
- Expected format:
50
- Column 0: radial distance in mm
51
- Column 2: cumulative PSF values
52
-
53
- Returns
54
- -------
55
- numpy.ndarray
56
- Loaded and processed data with radius in cm and normalized cumulative PSF.
57
- """
58
- d_type = {"names": (RADIUS_CM, CUMULATIVE_PSF), "formats": ("f8", "f8")}
59
- data = np.loadtxt(data_file, dtype=d_type, usecols=(0, 2))
60
- data[RADIUS_CM] *= 0.1 # Convert from mm to cm
61
- data[CUMULATIVE_PSF] /= np.max(np.abs(data[CUMULATIVE_PSF])) # Normalize to max = 1.0
62
- return data
42
+ # Create header mode
43
+ header_lines = [f"# {title}", f"# Telescope: {tel_model.name}"]
44
+ if additional_info:
45
+ for key, val in additional_info.items():
46
+ header_lines.append(f"# {key}: {val}")
47
+ header_lines.extend(["#" + "=" * 65, ""])
48
+ return "\n".join(header_lines) + "\n"
63
49
 
64
50
 
65
51
  def calculate_rmsd(data, sim):
66
- """Calculate Root Mean Squared Deviation to be used as metric to find the best parameters."""
52
+ """Calculate RMSD between measured and simulated cumulative PSF curves."""
67
53
  return np.sqrt(np.mean((data - sim) ** 2))
68
54
 
69
55
 
70
- def add_parameters(
71
- all_parameters,
72
- mirror_reflection,
73
- mirror_align,
74
- mirror_reflection_fraction=0.15,
75
- mirror_reflection_2=0.035,
76
- ):
77
- """
78
- Transform and add parameters to the all_parameters list.
79
-
80
- Parameters
81
- ----------
82
- mirror_reflection : float
83
- The random angle of mirror reflection.
84
- mirror_align : float
85
- The random angle for mirror alignment (both horizontal and vertical).
86
- mirror_reflection_fraction : float, optional
87
- The fraction of the mirror reflection. Default is 0.15.
88
- mirror_reflection_2 : float, optional
89
- A secondary random angle for mirror reflection. Default is 0.035.
90
-
91
- Returns
92
- -------
93
- None
94
- Updates the all_parameters list in place.
95
- """
96
- pars = {
97
- "mirror_reflection_random_angle": [
98
- mirror_reflection,
99
- mirror_reflection_fraction,
100
- mirror_reflection_2,
101
- ],
102
- "mirror_align_random_horizontal": [mirror_align, 28.0, 0.0, 0.0],
103
- "mirror_align_random_vertical": [mirror_align, 28.0, 0.0, 0.0],
104
- }
105
- all_parameters.append(pars)
56
+ def calculate_ks_statistic(data, sim):
57
+ """Calculate the KS statistic between measured and simulated cumulative PSF curves."""
58
+ return stats.ks_2samp(data, sim)
106
59
 
107
60
 
108
61
  def get_previous_values(tel_model):
109
62
  """
110
- Retrieve previous parameter values from the telescope model.
63
+ Retrieve current PSF parameter values from the telescope model.
111
64
 
112
65
  Parameters
113
66
  ----------
114
67
  tel_model : TelescopeModel
115
- Telescope model object.
68
+ Telescope model object containing parameter configurations.
116
69
 
117
70
  Returns
118
71
  -------
119
- tuple
120
- Tuple containing the previous values of mirror_reflection_random_angle (first entry),
121
- mirror_reflection_fraction, second entry), mirror_reflection_random_angle (third entry),
122
- and mirror_align_random_horizontal/vertical.
123
- """
124
- split_par = tel_model.get_parameter_value("mirror_reflection_random_angle")
125
- mrra_0, mfr_0, mrra2_0 = split_par[0], split_par[1], split_par[2]
126
- mar_0 = tel_model.get_parameter_value("mirror_align_random_horizontal")[0]
127
- logger.debug(
128
- "Previous parameter values:\n"
129
- f"MRRA = {mrra_0!s}\n"
130
- f"MRF = {mfr_0!s}\n"
131
- f"MRRA2 = {mrra2_0!s}\n"
132
- f"MAR = {mar_0!s}\n"
133
- )
134
- return mrra_0, mfr_0, mrra2_0, mar_0
135
-
136
-
137
- def generate_random_parameters(
138
- all_parameters, n_runs, args_dict, mrra_0, mfr_0, mrra2_0, mar_0, tel_model
139
- ):
140
- """
141
- Generate random parameters for tuning.
142
-
143
- The parameter ranges around the previous values are configurable via module constants.
144
-
145
- Parameters
146
- ----------
147
- all_parameters : list
148
- List to store all parameter sets.
149
- n_runs : int
150
- Number of random parameter combinations to test.
151
- args_dict : dict
152
- Dictionary containing parsed command-line arguments.
153
- mrra_0 : float
154
- Initial value of mirror_reflection_random_angle.
155
- mfr_0 : float
156
- Initial value of mirror_reflection_fraction.
157
- mrra2_0 : float
158
- Initial value of the second mirror_reflection_random_angle.
159
- mar_0 : float
160
- Initial value of mirror_align_random_horizontal/vertical.
161
- tel_model : TelescopeModel
162
- Telescope model object to check if it's a dual mirror telescope.
72
+ dict
73
+ Dictionary containing current values of PSF optimization parameters:
74
+ - 'mirror_reflection_random_angle': Random reflection angle parameters
75
+ - 'mirror_align_random_horizontal': Horizontal alignment parameters
76
+ - 'mirror_align_random_vertical': Vertical alignment parameters
163
77
  """
164
- if args_dict["fixed"]:
165
- logger.debug("fixed=True - First entry of mirror_reflection_random_angle is kept fixed.")
166
-
167
- is_dual_mirror = model_utils.is_two_mirror_telescope(tel_model.name)
168
- if is_dual_mirror:
169
- mar_fixed_value = 0.0
170
- else:
171
- mar_fixed_value = None
172
-
173
- for _ in range(n_runs):
174
- mrra_range = MRRA_RANGE_DEFAULT if not args_dict["fixed"] else 0
175
- mrf_range = MRF_RANGE_DEFAULT
176
- mrra2_range = MRRA2_RANGE_DEFAULT
177
- mar_range = MAR_RANGE_DEFAULT
178
- rng = np.random.default_rng(seed=args_dict.get("random_seed"))
179
- mrra = rng.uniform(max(mrra_0 - mrra_range, 0), mrra_0 + mrra_range)
180
- mrf = rng.uniform(max(mfr_0 - mrf_range, 0), mfr_0 + mrf_range)
181
- mrra2 = rng.uniform(max(mrra2_0 - mrra2_range, 0), mrra2_0 + mrra2_range)
182
-
183
- # Set mar to 0 for dual mirror telescopes, otherwise use random value
184
- if mar_fixed_value is not None:
185
- mar = mar_fixed_value
186
- else:
187
- mar = rng.uniform(max(mar_0 - mar_range, 0), mar_0 + mar_range)
188
-
189
- add_parameters(all_parameters, mrra, mar, mrf, mrra2)
78
+ return {
79
+ "mirror_reflection_random_angle": tel_model.get_parameter_value(
80
+ "mirror_reflection_random_angle"
81
+ ),
82
+ "mirror_align_random_horizontal": tel_model.get_parameter_value(
83
+ "mirror_align_random_horizontal"
84
+ ),
85
+ "mirror_align_random_vertical": tel_model.get_parameter_value(
86
+ "mirror_align_random_vertical"
87
+ ),
88
+ }
190
89
 
191
90
 
192
91
  def _run_ray_tracing_simulation(tel_model, site_model, args_dict, pars):
193
- """
194
- Run a ray tracing simulation with the given telescope parameters.
195
-
196
- Parameters
197
- ----------
198
- tel_model : TelescopeModel
199
- Telescope model object.
200
- site_model : SiteModel
201
- Site model object.
202
- args_dict : dict
203
- Dictionary containing parsed command-line arguments.
204
- pars : dict
205
- Parameter set dictionary.
206
-
207
- Returns
208
- -------
209
- tuple
210
- (d80, simulated_data) - D80 value and simulated data from ray tracing.
211
- """
212
- if pars is not None:
213
- tel_model.change_multiple_parameters(**pars)
214
- else:
92
+ """Run a ray tracing simulation with the given telescope parameters."""
93
+ if pars is None:
215
94
  raise ValueError("No best parameters found")
216
95
 
96
+ tel_model.change_multiple_parameters(**pars)
217
97
  ray = RayTracing(
218
98
  telescope_model=tel_model,
219
99
  site_model=site_model,
@@ -225,568 +105,1122 @@ def _run_ray_tracing_simulation(tel_model, site_model, args_dict, pars):
225
105
  ray.simulate(test=args_dict.get("test", False), force=True)
226
106
  ray.analyze(force=True, use_rx=False)
227
107
  im = ray.images()[0]
228
- d80 = im.get_psf()
229
-
230
- return d80, im
231
-
232
-
233
- def _create_psf_simulation_plot(data_to_plot, pars, d80, rmsd, is_best, pdf_pages):
234
- """
235
- Create a plot for PSF simulation results.
236
-
237
- Parameters
238
- ----------
239
- data_to_plot : dict
240
- Data dictionary for plotting.
241
- pars : dict
242
- Parameter set dictionary.
243
- d80 : float
244
- D80 value.
245
- rmsd : float
246
- RMSD value.
247
- is_best : bool
248
- Whether this is the best parameter set.
249
- pdf_pages : PdfPages
250
- PDF pages object for saving plots.
251
- """
252
- fig = visualize.plot_1d(
253
- data_to_plot,
254
- plot_difference=True,
255
- no_markers=True,
256
- )
257
- ax = fig.get_axes()[0]
258
- ax.set_ylim(0, 1.05)
259
- ax.set_ylabel(CUMULATIVE_PSF)
260
-
261
- title_prefix = "* " if is_best else ""
262
- ax.set_title(
263
- f"{title_prefix}refl_rnd = "
264
- f"{pars['mirror_reflection_random_angle'][0]:.5f}, "
265
- f"{pars['mirror_reflection_random_angle'][1]:.5f}, "
266
- f"{pars['mirror_reflection_random_angle'][2]:.5f}\n"
267
- f"align_rnd = {pars['mirror_align_random_vertical'][0]:.5f}, "
268
- f"{pars['mirror_align_random_vertical'][1]:.5f}, "
269
- f"{pars['mirror_align_random_vertical'][2]:.5f}, "
270
- f"{pars['mirror_align_random_vertical'][3]:.5f}"
271
- )
272
-
273
- d80_color = "red" if is_best else "black"
274
- d80_weight = "bold" if is_best else "normal"
275
- d80_text = f"D80 = {d80:.5f} cm"
276
-
277
- ax.text(
278
- 0.5,
279
- 0.3,
280
- f"{d80_text}\nRMSD = {rmsd:.4f}",
281
- verticalalignment="center",
282
- horizontalalignment="left",
283
- transform=ax.transAxes,
284
- color=d80_color,
285
- weight=d80_weight,
286
- bbox={"boxstyle": "round,pad=0.3", "facecolor": "yellow", "alpha": 0.7}
287
- if is_best
288
- else None,
289
- )
290
-
291
- if is_best:
292
- fig.text(
293
- 0.02,
294
- 0.02,
295
- "* Best parameter set (lowest RMSD)",
296
- fontsize=8,
297
- style="italic",
298
- color="red",
299
- )
300
-
301
- pdf_pages.savefig(fig, bbox_inches="tight")
302
- plt.clf()
108
+ return im.get_psf(), im
303
109
 
304
110
 
305
111
  def run_psf_simulation(
306
112
  tel_model,
307
- site_model,
113
+ site,
308
114
  args_dict,
309
115
  pars,
310
116
  data_to_plot,
311
117
  radius,
312
118
  pdf_pages=None,
313
119
  is_best=False,
314
- return_simulated_data=False,
120
+ use_ks_statistic=False,
315
121
  ):
316
122
  """
317
- Run the simulation for one set of parameters and return D80, RMSD.
123
+ Run PSF simulation for given parameters and calculate optimization metric.
318
124
 
319
125
  Parameters
320
126
  ----------
321
127
  tel_model : TelescopeModel
322
- Telescope model object.
323
- site_model : SiteModel
324
- Site model object.
128
+ Telescope model object to be configured with the test parameters.
129
+ site : Site
130
+ Site model object with environmental conditions.
325
131
  args_dict : dict
326
- Dictionary containing parsed command-line arguments.
132
+ Dictionary containing simulation configuration arguments.
327
133
  pars : dict
328
- Parameter set dictionary.
134
+ Dictionary of parameter values to test in the simulation.
329
135
  data_to_plot : dict
330
- Data dictionary for plotting.
136
+ Dictionary containing measured PSF data under "measured" key.
331
137
  radius : array-like
332
- Radius data.
138
+ Radius values in cm for PSF evaluation.
333
139
  pdf_pages : PdfPages, optional
334
- PDF pages object for plotting. If None, no plotting is done.
140
+ PDF pages object for saving plots (default: None).
335
141
  is_best : bool, optional
336
- Whether this is the best parameter set for highlighting in plots.
337
- return_simulated_data : bool, optional
338
- If True, returns simulated data as third element in return tuple.
142
+ Flag indicating if this is the best parameter set (default: False).
143
+ use_ks_statistic : bool, optional
144
+ If True, use KS statistic as metric; if False, use RMSD (default: False).
339
145
 
340
146
  Returns
341
147
  -------
342
- tuple
343
- (d80, rmsd) if return_simulated_data=False
344
- (d80, rmsd, simulated_data) if return_simulated_data=True
148
+ tuple of (float, float, float or None, array)
149
+ - d80: D80 diameter of the simulated PSF in cm
150
+ - metric: RMSD or KS statistic value
151
+ - p_value: p-value from KS test (None if using RMSD)
152
+ - simulated_data: Structured array with simulated cumulative PSF data
345
153
  """
346
- d80, im = _run_ray_tracing_simulation(tel_model, site_model, args_dict, pars)
154
+ d80, im = _run_ray_tracing_simulation(tel_model, site, args_dict, pars)
347
155
 
348
156
  if radius is None:
349
157
  raise ValueError("Radius data is not available.")
350
158
 
351
159
  simulated_data = im.get_cumulative_data(radius * u.cm)
352
- rmsd = calculate_rmsd(data_to_plot["measured"][CUMULATIVE_PSF], simulated_data[CUMULATIVE_PSF])
160
+
161
+ if use_ks_statistic:
162
+ ks_statistic, p_value = calculate_ks_statistic(
163
+ data_to_plot["measured"][CUMULATIVE_PSF], simulated_data[CUMULATIVE_PSF]
164
+ )
165
+ metric = ks_statistic
166
+ else:
167
+ metric = calculate_rmsd(
168
+ data_to_plot["measured"][CUMULATIVE_PSF], simulated_data[CUMULATIVE_PSF]
169
+ )
170
+ p_value = None
353
171
 
354
172
  # Handle plotting if requested
355
173
  if pdf_pages is not None and args_dict.get("plot_all", False):
356
174
  data_to_plot["simulated"] = simulated_data
357
- _create_psf_simulation_plot(data_to_plot, pars, d80, rmsd, is_best, pdf_pages)
175
+ plot_psf.create_psf_parameter_plot(
176
+ data_to_plot,
177
+ pars,
178
+ d80,
179
+ metric,
180
+ is_best,
181
+ pdf_pages,
182
+ p_value=p_value,
183
+ use_ks_statistic=use_ks_statistic,
184
+ )
358
185
  del data_to_plot["simulated"]
359
186
 
360
- return (d80, rmsd, simulated_data) if return_simulated_data else (d80, rmsd)
187
+ return d80, metric, p_value, simulated_data
361
188
 
362
189
 
363
190
  def load_and_process_data(args_dict):
364
191
  """
365
- Load and process data if specified in the command-line arguments.
192
+ Load and process PSF measurement data from ECSV file.
193
+
194
+ Parameters
195
+ ----------
196
+ args_dict : dict
197
+ Dictionary containing command-line arguments with 'data' and 'model_path' keys.
366
198
 
367
199
  Returns
368
200
  -------
369
- - data_to_plot: OrderedDict containing loaded and processed data.
370
- - radius: Radius data from loaded data (if available).
201
+ tuple of (OrderedDict, array)
202
+ - data_dict: OrderedDict with "measured" key containing structured array
203
+ of radius and cumulative PSF data
204
+ - radius: Array of radius values in cm
205
+
206
+ Raises
207
+ ------
208
+ FileNotFoundError
209
+ If no data file is specified in args_dict.
371
210
  """
372
- data_to_plot = OrderedDict()
373
- radius = None
374
- if args_dict["data"] is not None:
375
- data_file = gen.find_file(args_dict["data"], args_dict["model_path"])
376
- data_to_plot["measured"] = load_psf_data(data_file)
377
- radius = data_to_plot["measured"][RADIUS_CM]
378
- return data_to_plot, radius
211
+ if args_dict["data"] is None:
212
+ raise FileNotFoundError("No data file specified for PSF optimization.")
213
+
214
+ data_file = gen.find_file(args_dict["data"], args_dict["model_path"])
215
+ table = Table.read(data_file, format="ascii.ecsv")
216
+
217
+ radius_column = next((col for col in table.colnames if "radius" in col.lower()), None)
218
+ integral_psf_column = next((col for col in table.colnames if "integral" in col.lower()), None)
379
219
 
220
+ # Create structured array with converted data
221
+ d_type = {"names": (RADIUS, CUMULATIVE_PSF), "formats": ("f8", "f8")}
222
+ data = np.zeros(len(table), dtype=d_type)
380
223
 
381
- def _create_plot_for_parameters(pars, rmsd, d80, simulated_data, data_to_plot, is_best, pdf_pages):
224
+ data[RADIUS] = table[radius_column].to(u.cm).value
225
+ data[CUMULATIVE_PSF] = table[integral_psf_column]
226
+ data[CUMULATIVE_PSF] /= np.max(np.abs(data[CUMULATIVE_PSF])) # Normalize to max = 1.0
227
+
228
+ return OrderedDict([("measured", data)]), data[RADIUS]
229
+
230
+
231
+ def write_tested_parameters_to_file(results, best_pars, best_d80, output_dir, tel_model):
382
232
  """
383
- Create a single plot for a parameter set.
233
+ Write optimization results and tested parameters to a log file.
384
234
 
385
235
  Parameters
386
236
  ----------
387
- pars : dict
388
- Parameter set dictionary
389
- rmsd : float
390
- RMSD value for this parameter set
391
- d80 : float
392
- D80 value for this parameter set
393
- simulated_data : array
394
- Simulated data for plotting
395
- data_to_plot : dict
396
- Data dictionary for plotting
397
- is_best : bool
398
- Whether this is the best parameter set
399
- pdf_pages : PdfPages
400
- PDF pages object to save the plot
401
- """
402
- original_simulated = data_to_plot.get("simulated")
403
- data_to_plot["simulated"] = simulated_data
237
+ results : list
238
+ List of tuples containing (parameters, ks_statistic, p_value, d80, simulated_data)
239
+ for each tested parameter set.
240
+ best_pars : dict
241
+ Dictionary containing the best parameter values found.
242
+ best_d80 : float
243
+ D80 diameter in cm for the best parameter set.
244
+ output_dir : Path
245
+ Directory where the log file will be written.
246
+ tel_model : TelescopeModel
247
+ Telescope model object for naming the output file.
404
248
 
405
- fig = visualize.plot_1d(
406
- data_to_plot,
407
- plot_difference=True,
408
- no_markers=True,
409
- )
410
- ax = fig.get_axes()[0]
411
- ax.set_ylim(0, 1.05)
412
- ax.set_ylabel(CUMULATIVE_PSF)
413
-
414
- title_prefix = "* " if is_best else ""
415
-
416
- ax.set_title(
417
- f"{title_prefix}reflection = "
418
- f"{pars['mirror_reflection_random_angle'][0]:.5f}, "
419
- f"{pars['mirror_reflection_random_angle'][1]:.5f}, "
420
- f"{pars['mirror_reflection_random_angle'][2]:.5f}\n"
421
- f"align_vertical = {pars['mirror_align_random_vertical'][0]:.5f}, "
422
- f"{pars['mirror_align_random_vertical'][1]:.5f}, "
423
- f"{pars['mirror_align_random_vertical'][2]:.5f}, "
424
- f"{pars['mirror_align_random_vertical'][3]:.5f}\n"
425
- f"align_horizontal = {pars['mirror_align_random_horizontal'][0]:.5f}, "
426
- f"{pars['mirror_align_random_horizontal'][1]:.5f}, "
427
- f"{pars['mirror_align_random_horizontal'][2]:.5f}, "
428
- f"{pars['mirror_align_random_horizontal'][3]:.5f}"
429
- )
249
+ Returns
250
+ -------
251
+ Path
252
+ Path to the created log file.
253
+ """
254
+ param_file = output_dir.joinpath(f"psf_optimization_{tel_model.name}.log")
255
+ with open(param_file, "w", encoding="utf-8") as f:
256
+ header = _create_log_header_and_format_value(
257
+ "PSF Parameter Optimization Log",
258
+ tel_model,
259
+ {"Total parameter sets tested": len(results)},
260
+ )
261
+ f.write(header)
430
262
 
431
- d80_color = "red" if is_best else "black"
432
- d80_weight = "bold" if is_best else "normal"
433
-
434
- ax.text(
435
- 0.5,
436
- 0.3,
437
- f"D80 = {d80:.5f} cm\nRMSD = {rmsd:.4f}",
438
- verticalalignment="center",
439
- horizontalalignment="left",
440
- transform=ax.transAxes,
441
- color=d80_color,
442
- weight=d80_weight,
443
- bbox={"boxstyle": "round,pad=0.3", "facecolor": "yellow", "alpha": 0.7}
444
- if is_best
445
- else None,
446
- )
263
+ f.write("PARAMETER TESTING RESULTS:\n")
264
+ for i, (pars, ks_statistic, p_value, d80, _) in enumerate(results):
265
+ status = "BEST" if pars is best_pars else "TESTED"
266
+ f.write(
267
+ f"[{status}] Set {i + 1:03d}: KS_stat={ks_statistic:.5f}, "
268
+ f"p_value={p_value:.5f}, D80={d80:.5f} cm\n"
269
+ )
270
+ for par, value in pars.items():
271
+ f.write(f" {par}: {value}\n")
272
+ f.write("\n")
447
273
 
448
- if is_best:
449
- fig.text(
450
- 0.02,
451
- 0.02,
452
- "* Best parameter set (lowest RMSD)",
453
- fontsize=8,
454
- style="italic",
455
- color="red",
456
- )
274
+ f.write("OPTIMIZATION SUMMARY:\n")
275
+ f.write(f"Best KS statistic: {min(result[1] for result in results):.5f}\n")
276
+ f.write(f"Best D80: {best_d80:.5f} cm\n")
277
+ f.write("\nOPTIMIZED PARAMETERS:\n")
278
+ for par, value in best_pars.items():
279
+ f.write(f"{par}: {value}\n")
280
+ return param_file
457
281
 
458
- pdf_pages.savefig(fig, bbox_inches="tight")
459
- plt.clf()
460
282
 
461
- if original_simulated is not None:
462
- data_to_plot["simulated"] = original_simulated
283
+ def _add_units_to_psf_parameters(best_pars):
284
+ """Add astropy units to PSF parameters based on their schemas."""
285
+ psf_pars_with_units = {}
286
+ for param_name, param_values in best_pars.items():
287
+ if param_name == "mirror_reflection_random_angle":
288
+ psf_pars_with_units[param_name] = [
289
+ param_values[0] * u.deg,
290
+ param_values[1] * u.dimensionless_unscaled,
291
+ param_values[2] * u.deg,
292
+ ]
293
+ elif param_name in ["mirror_align_random_horizontal", "mirror_align_random_vertical"]:
294
+ psf_pars_with_units[param_name] = [
295
+ param_values[0] * u.deg,
296
+ param_values[1] * u.deg,
297
+ param_values[2] * u.dimensionless_unscaled,
298
+ param_values[3] * u.dimensionless_unscaled,
299
+ ]
300
+ else:
301
+ psf_pars_with_units[param_name] = param_values
302
+ return psf_pars_with_units
463
303
 
464
304
 
465
- def _create_all_plots(results, best_pars, data_to_plot, pdf_pages):
305
+ def export_psf_parameters(best_pars, telescope, parameter_version, output_dir):
466
306
  """
467
- Create plots for all parameter sets if requested.
307
+ Export optimized PSF parameters as simulation model parameter files.
468
308
 
469
309
  Parameters
470
310
  ----------
471
- results : list
472
- List of (pars, rmsd, d80, simulated_data) tuples
473
311
  best_pars : dict
474
- Best parameter set for highlighting
312
+ Dictionary containing the optimized parameter values.
313
+ telescope : str
314
+ Telescope name for the parameter files.
315
+ parameter_version : str
316
+ Version string for the parameter files.
317
+ output_dir : Path
318
+ Base directory for parameter file output.
319
+
320
+ Notes
321
+ -----
322
+ Creates individual JSON files for each optimized parameter with
323
+ units. Files are saved in the format:
324
+ {output_dir}/{telescope}/{parameter_name}-{parameter_version}.json
325
+
326
+ Raises
327
+ ------
328
+ ValueError, KeyError, OSError
329
+ If parameter export fails due to invalid values, missing keys, or file I/O errors.
330
+ """
331
+ try:
332
+ psf_pars_with_units = _add_units_to_psf_parameters(best_pars)
333
+ parameter_output_path = output_dir.parent / telescope
334
+ for parameter_name, parameter_value in psf_pars_with_units.items():
335
+ writer.ModelDataWriter.dump_model_parameter(
336
+ parameter_name=parameter_name,
337
+ value=parameter_value,
338
+ instrument=telescope,
339
+ parameter_version=parameter_version,
340
+ output_file=f"{parameter_name}-{parameter_version}.json",
341
+ output_path=parameter_output_path,
342
+ use_plain_output_path=True,
343
+ )
344
+ logger.info(f"simulation model parameter files exported to {output_dir}")
345
+
346
+ except (ValueError, KeyError, OSError) as e:
347
+ logger.error(f"Error exporting simulation parameters: {e}")
348
+
349
+
350
+ def _calculate_param_gradient(
351
+ tel_model,
352
+ site_model,
353
+ args_dict,
354
+ current_params,
355
+ data_to_plot,
356
+ radius,
357
+ current_rmsd,
358
+ param_name,
359
+ param_values,
360
+ epsilon,
361
+ use_ks_statistic,
362
+ ):
363
+ """
364
+ Calculate numerical gradient for a single parameter using finite differences.
365
+
366
+ The gradient is calculated using forward finite differences:
367
+ gradient = (f(x + epsilon) - f(x)) / epsilon
368
+
369
+ Parameters
370
+ ----------
371
+ tel_model : TelescopeModel
372
+ The telescope model object containing the current parameter configuration.
373
+ site_model : SiteModel
374
+ The site model object with environmental conditions.
375
+ args_dict : dict
376
+ Dictionary containing simulation arguments and configuration options.
377
+ current_params : dict
378
+ Dictionary of current parameter values for all optimization parameters.
475
379
  data_to_plot : dict
476
- Data dictionary for plotting
477
- pdf_pages : PdfPages
478
- PDF pages object to save plots
380
+ Dictionary containing measured PSF data with "measured" key.
381
+ radius : array-like
382
+ Radius values in cm for PSF evaluation.
383
+ current_rmsd : float
384
+ Current RMSD at the current parameter configuration.
385
+ param_name : str
386
+ Name of the parameter for which to calculate the gradient.
387
+ param_values : float or list
388
+ Current value(s) of the parameter. Can be a single value or list of values.
389
+ epsilon : float
390
+ Small perturbation value for finite difference calculation.
391
+ use_ks_statistic : bool
392
+ If True, calculate gradient with respect to KS statistic; if False, use RMSD.
393
+
394
+ Returns
395
+ -------
396
+ float or list
397
+ Gradient value(s) for the parameter. Returns a single float if param_values
398
+ is a single value, or a list of gradients if param_values is a list.
399
+
400
+ If a simulation fails during gradient calculation, a gradient of 0.0 is assigned
401
+ for that component to ensure the optimization can continue.
479
402
  """
480
- logger.info("Creating plots for all parameter sets...")
403
+ param_gradients = []
404
+ values_list = param_values if isinstance(param_values, list) else [param_values]
481
405
 
482
- for i, (pars, rmsd, d80, simulated_data) in enumerate(results):
483
- is_best = pars is best_pars
484
- logger.info(f"Creating plot {i + 1}/{len(results)}{' (BEST)' if is_best else ''}")
406
+ for i, value in enumerate(values_list):
407
+ perturbed_params = {
408
+ k: v.copy() if isinstance(v, list) else v for k, v in current_params.items()
409
+ }
485
410
 
486
- _create_plot_for_parameters(
487
- pars, rmsd, d80, simulated_data, data_to_plot, is_best, pdf_pages
488
- )
411
+ if isinstance(param_values, list):
412
+ perturbed_params[param_name][i] = value + epsilon
413
+ else:
414
+ perturbed_params[param_name] = value + epsilon
489
415
 
416
+ try:
417
+ _, perturbed_rmsd, _, _ = run_psf_simulation(
418
+ tel_model,
419
+ site_model,
420
+ args_dict,
421
+ perturbed_params,
422
+ data_to_plot,
423
+ radius,
424
+ pdf_pages=None,
425
+ is_best=False,
426
+ use_ks_statistic=use_ks_statistic,
427
+ )
428
+ param_gradients.append((perturbed_rmsd - current_rmsd) / epsilon)
429
+ except (ValueError, RuntimeError):
430
+ param_gradients.append(0.0)
490
431
 
491
- def find_best_parameters(
492
- all_parameters, tel_model, site_model, args_dict, data_to_plot, radius, pdf_pages=None
432
+ return param_gradients[0] if not isinstance(param_values, list) else param_gradients
433
+
434
+
435
+ def calculate_gradient(
436
+ tel_model,
437
+ site_model,
438
+ args_dict,
439
+ current_params,
440
+ data_to_plot,
441
+ radius,
442
+ current_rmsd,
443
+ epsilon=0.0005,
444
+ use_ks_statistic=False,
493
445
  ):
494
446
  """
495
- Find the best parameters by running simulations for all parameter sets.
447
+ Calculate numerical gradients for all optimization parameters.
448
+
449
+ Parameters
450
+ ----------
451
+ tel_model : TelescopeModel
452
+ Telescope model object for simulations.
453
+ site_model : SiteModel
454
+ Site model object with environmental conditions.
455
+ args_dict : dict
456
+ Dictionary containing simulation configuration arguments.
457
+ current_params : dict
458
+ Dictionary of current parameter values for all optimization parameters.
459
+ data_to_plot : dict
460
+ Dictionary containing measured PSF data.
461
+ radius : array-like
462
+ Radius values in cm for PSF evaluation.
463
+ current_rmsd : float
464
+ Current RMSD or KS statistic value.
465
+ epsilon : float, optional
466
+ Perturbation value for finite difference calculation (default: 0.0005).
467
+ use_ks_statistic : bool, optional
468
+ If True, calculate gradients for KS statistic; if False, use RMSD (default: False).
496
469
 
497
- Loop over all parameter sets, run the simulation, compute RMSD,
498
- and return the best parameters and their RMSD.
470
+ Returns
471
+ -------
472
+ dict
473
+ Dictionary mapping parameter names to their gradient values.
474
+ For parameters with multiple components, gradients are returned as lists.
499
475
  """
500
- best_rmsd = float("inf")
501
- best_pars = None
502
- best_d80 = None
503
- results = [] # Store (pars, rmsd, d80, simulated_data)
476
+ gradients = {}
477
+
478
+ for param_name, param_values in current_params.items():
479
+ gradients[param_name] = _calculate_param_gradient(
480
+ tel_model,
481
+ site_model,
482
+ args_dict,
483
+ current_params,
484
+ data_to_plot,
485
+ radius,
486
+ current_rmsd,
487
+ param_name,
488
+ param_values,
489
+ epsilon,
490
+ use_ks_statistic,
491
+ )
504
492
 
505
- logger.info(f"Running {len(all_parameters)} simulations...")
493
+ return gradients
494
+
495
+
496
+ def apply_gradient_step(current_params, gradients, learning_rate):
497
+ """
498
+ Apply gradient descent step to update parameters.
506
499
 
507
- for i, pars in enumerate(all_parameters):
500
+ Parameters
501
+ ----------
502
+ current_params : dict
503
+ Dictionary of current parameter values.
504
+ gradients : dict
505
+ Dictionary of gradient values for each parameter.
506
+ learning_rate : float
507
+ Step size for the gradient descent update.
508
+
509
+ Returns
510
+ -------
511
+ dict
512
+ Dictionary of updated parameter values after applying the gradient step.
513
+ """
514
+ new_params = {}
515
+ for param_name, param_values in current_params.items():
516
+ param_gradients = gradients[param_name]
517
+
518
+ if isinstance(param_values, list):
519
+ new_params[param_name] = [
520
+ value - learning_rate * gradient
521
+ for value, gradient in zip(param_values, param_gradients)
522
+ ]
523
+ else:
524
+ new_params[param_name] = param_values - learning_rate * param_gradients
525
+
526
+ return new_params
527
+
528
+
529
+ def _perform_gradient_step_with_retries(
530
+ tel_model,
531
+ site_model,
532
+ args_dict,
533
+ current_params,
534
+ current_metric,
535
+ data_to_plot,
536
+ radius,
537
+ learning_rate,
538
+ max_retries=3,
539
+ ):
540
+ """
541
+ Attempt gradient descent step with adaptive learning rate reduction on rejection.
542
+
543
+ The learning rate reduction strategy follows these rules:
544
+ - If step is rejected: learning_rate *= 0.7
545
+ - If attempt number < number of max retries then try again
546
+ - If learning_rate drops below 1e-5: reset to 0.001
547
+ - If all retries fail: returns None values with step_accepted=False
548
+
549
+ This adaptive approach helps navigate local minima and ensures robust convergence
550
+ by automatically adjusting the step size based on optimization progress.
551
+
552
+ Parameters
553
+ ----------
554
+ tel_model : TelescopeModel
555
+ Telescope model object containing the current parameter configuration.
556
+ site_model : SiteModel
557
+ Site model object with environmental conditions for ray tracing simulations.
558
+ args_dict : dict
559
+ Dictionary containing simulation configuration arguments and settings.
560
+ current_params : dict
561
+ Dictionary of current parameter values for all optimization parameters.
562
+ current_metric : float
563
+ Current optimization metric value (RMSD or KS statistic) to improve upon.
564
+ data_to_plot : dict
565
+ Dictionary containing measured PSF data under "measured" key for comparison.
566
+ radius : array-like
567
+ Radius values in cm for PSF evaluation and comparison.
568
+ learning_rate : float
569
+ Initial learning rate for the gradient descent step.
570
+ max_retries : int, optional
571
+ Maximum number of attempts with learning rate reduction (default: 3).
572
+
573
+ Returns
574
+ -------
575
+ tuple of (dict, float, float, float or None, array, bool, float)
576
+ - new_params: Updated parameter dictionary if step accepted, None if rejected
577
+ - new_d80: D80 diameter in cm for new parameters, None if step rejected
578
+ - new_metric: New optimization metric value, None if step rejected
579
+ - new_p_value: p-value from KS test if applicable, None otherwise
580
+ - new_simulated_data: Simulated PSF data array, None if step rejected
581
+ - step_accepted: Boolean indicating if any step was accepted
582
+ - final_learning_rate: Learning rate after potential reductions
583
+
584
+ """
585
+ current_lr = learning_rate
586
+
587
+ for attempt in range(max_retries):
508
588
  try:
509
- logger.info(f"Running simulation {i + 1}/{len(all_parameters)}")
510
- d80, rmsd, simulated_data = run_psf_simulation(
589
+ gradients = calculate_gradient(
511
590
  tel_model,
512
591
  site_model,
513
592
  args_dict,
514
- pars,
593
+ current_params,
594
+ data_to_plot,
595
+ radius,
596
+ current_metric,
597
+ use_ks_statistic=False,
598
+ )
599
+ new_params = apply_gradient_step(current_params, gradients, current_lr)
600
+
601
+ new_d80, new_metric, new_p_value, new_simulated_data = run_psf_simulation(
602
+ tel_model,
603
+ site_model,
604
+ args_dict,
605
+ new_params,
515
606
  data_to_plot,
516
607
  radius,
517
- return_simulated_data=True,
518
608
  pdf_pages=None,
609
+ is_best=False,
610
+ use_ks_statistic=False,
519
611
  )
520
- except (ValueError, RuntimeError) as e:
521
- logger.warning(f"Simulation failed for parameters {pars}: {e}")
612
+
613
+ if new_metric < current_metric:
614
+ return (
615
+ new_params,
616
+ new_d80,
617
+ new_metric,
618
+ new_p_value,
619
+ new_simulated_data,
620
+ True,
621
+ current_lr,
622
+ )
623
+
624
+ logger.info(
625
+ f"Step rejected (RMSD {current_metric:.6f} -> {new_metric:.6f}), "
626
+ f"reducing learning rate {current_lr:.6f} -> {current_lr * 0.7:.6f}"
627
+ )
628
+ current_lr *= 0.7
629
+
630
+ if current_lr < 1e-5:
631
+ current_lr = 0.001
632
+
633
+ except (ValueError, RuntimeError, KeyError) as e:
634
+ logger.warning(f"Simulation failed on attempt {attempt + 1}: {e}")
522
635
  continue
523
636
 
524
- results.append((pars, rmsd, d80, simulated_data))
525
- if rmsd < best_rmsd:
526
- best_rmsd = rmsd
527
- best_pars = pars
528
- best_d80 = d80
637
+ return None, None, None, None, None, False, current_lr
529
638
 
530
- logger.info(f"Best RMSD found: {best_rmsd:.5f}")
531
639
 
532
- # Create all plots if requested
533
- if pdf_pages is not None and args_dict.get("plot_all", False) and results:
534
- _create_all_plots(results, best_pars, data_to_plot, pdf_pages)
640
+ def _create_step_plot(
641
+ pdf_pages,
642
+ args_dict,
643
+ data_to_plot,
644
+ current_params,
645
+ new_d80,
646
+ new_metric,
647
+ new_p_value,
648
+ new_simulated_data,
649
+ ):
650
+ """Create plot for an accepted gradient step."""
651
+ if pdf_pages is None or not args_dict.get("plot_all", False) or new_simulated_data is None:
652
+ return
653
+
654
+ data_to_plot["simulated"] = new_simulated_data
655
+ plot_psf.create_psf_parameter_plot(
656
+ data_to_plot,
657
+ current_params,
658
+ new_d80,
659
+ new_metric,
660
+ False,
661
+ pdf_pages,
662
+ p_value=new_p_value,
663
+ use_ks_statistic=False,
664
+ )
665
+ del data_to_plot["simulated"]
666
+
667
+
668
+ def _create_final_plot(
669
+ pdf_pages, tel_model, site_model, args_dict, best_params, data_to_plot, radius, best_d80
670
+ ):
671
+ """Create final plot for best parameters."""
672
+ if pdf_pages is None or best_params is None:
673
+ return
674
+
675
+ logger.info("Creating final plot for best parameters with both RMSD and KS statistic...")
676
+ _, best_ks_stat, best_p_value, best_simulated_data = run_psf_simulation(
677
+ tel_model,
678
+ site_model,
679
+ args_dict,
680
+ best_params,
681
+ data_to_plot,
682
+ radius,
683
+ pdf_pages=None,
684
+ is_best=False,
685
+ use_ks_statistic=True,
686
+ )
687
+ best_rmsd = calculate_rmsd(
688
+ data_to_plot["measured"][CUMULATIVE_PSF], best_simulated_data[CUMULATIVE_PSF]
689
+ )
535
690
 
536
- return best_pars, best_d80, results
691
+ data_to_plot["simulated"] = best_simulated_data
692
+ plot_psf.create_psf_parameter_plot(
693
+ data_to_plot,
694
+ best_params,
695
+ best_d80,
696
+ best_rmsd,
697
+ True,
698
+ pdf_pages,
699
+ p_value=best_p_value,
700
+ use_ks_statistic=False,
701
+ second_metric=best_ks_stat,
702
+ )
703
+ del data_to_plot["simulated"]
704
+ pdf_pages.close()
705
+ logger.info("Cumulative PSF plots saved")
537
706
 
538
707
 
539
- def create_d80_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, output_dir):
708
+ def run_gradient_descent_optimization(
709
+ tel_model,
710
+ site_model,
711
+ args_dict,
712
+ data_to_plot,
713
+ radius,
714
+ rmsd_threshold,
715
+ learning_rate,
716
+ output_dir,
717
+ ):
540
718
  """
541
- Create D80 vs off-axis angle plot using the best parameters.
719
+ Run gradient descent optimization to minimize PSF fitting metric.
542
720
 
543
721
  Parameters
544
722
  ----------
545
723
  tel_model : TelescopeModel
546
- Telescope model object.
724
+ Telescope model object to be optimized.
547
725
  site_model : SiteModel
548
- Site model object.
726
+ Site model object with environmental conditions.
549
727
  args_dict : dict
550
- Dictionary containing parsed command-line arguments.
551
- best_pars : dict
552
- Best parameter set.
728
+ Dictionary containing simulation configuration arguments.
729
+ data_to_plot : dict
730
+ Dictionary containing measured PSF data under "measured" key.
731
+ radius : array-like
732
+ Radius values in cm for PSF evaluation.
733
+ rmsd_threshold : float
734
+ Convergence threshold for RMSD improvement.
735
+ learning_rate : float
736
+ Initial learning rate for gradient descent steps.
553
737
  output_dir : Path
554
- Output directory for saving plots.
738
+ Directory for saving optimization plots and results.
739
+
740
+ Returns
741
+ -------
742
+ tuple of (dict, float, list)
743
+ - best_params: Dictionary of optimized parameter values
744
+ - best_d80: D80 diameter in cm for the best parameters
745
+ - results: List of (params, metric, p_value, d80, simulated_data) for each iteration
746
+
747
+ Returns None values if optimization fails or no measurement data is provided.
555
748
  """
556
- logger.info("Creating D80 vs off-axis angle plot with best parameters...")
557
-
558
- # Apply best parameters to telescope model
559
- tel_model.change_multiple_parameters(**best_pars)
560
-
561
- # Create off-axis angle array
562
- max_offset = args_dict.get("max_offset", MAX_OFFSET_DEFAULT)
563
- offset_steps = args_dict.get("offset_steps", OFFSET_STEPS_DEFAULT)
564
- off_axis_angles = np.linspace(
565
- 0,
566
- max_offset,
567
- int(max_offset / offset_steps) + 1,
749
+ if data_to_plot is None or radius is None:
750
+ logger.error("No PSF measurement data provided. Cannot run optimization.")
751
+ return None, None, []
752
+
753
+ current_params = get_previous_values(tel_model)
754
+ pdf_pages = plot_psf.setup_pdf_plotting(args_dict, output_dir, tel_model.name)
755
+ results = []
756
+
757
+ # Evaluate initial parameters
758
+ current_d80, current_metric, current_p_value, simulated_data = run_psf_simulation(
759
+ tel_model,
760
+ site_model,
761
+ args_dict,
762
+ current_params,
763
+ data_to_plot,
764
+ radius,
765
+ pdf_pages=pdf_pages if args_dict.get("plot_all", False) else None,
766
+ is_best=False,
767
+ use_ks_statistic=False,
568
768
  )
569
769
 
570
- ray = RayTracing(
571
- telescope_model=tel_model,
572
- site_model=site_model,
573
- simtel_path=args_dict["simtel_path"],
574
- zenith_angle=args_dict["zenith"] * u.deg,
575
- source_distance=args_dict["src_distance"] * u.km,
576
- off_axis_angle=off_axis_angles * u.deg,
770
+ results.append(
771
+ (current_params.copy(), current_metric, current_p_value, current_d80, simulated_data)
577
772
  )
773
+ best_metric, best_params, best_d80 = current_metric, current_params.copy(), current_d80
578
774
 
579
- logger.info(f"Running ray tracing for {len(off_axis_angles)} off-axis angles...")
580
- ray.simulate(test=args_dict.get("test", False), force=True)
581
- ray.analyze(force=True)
775
+ logger.info(f"Initial RMSD: {current_metric:.6f}, D80: {current_d80:.6f} cm")
776
+
777
+ iteration = 0
778
+ max_total_iterations = 100
779
+
780
+ while iteration < max_total_iterations:
781
+ if current_metric <= rmsd_threshold:
782
+ logger.info(
783
+ f"Optimization converged: RMSD {current_metric:.6f} <= "
784
+ f"threshold {rmsd_threshold:.6f}"
785
+ )
786
+ break
582
787
 
583
- for key in ["d80_cm", "d80_deg"]:
584
- plt.figure(figsize=(10, 6), tight_layout=True)
788
+ iteration += 1
789
+ logger.info(f"Gradient descent iteration {iteration}")
585
790
 
586
- ray.plot(key, marker="o", linestyle="-", color="blue", linewidth=2, markersize=6)
791
+ step_result = _perform_gradient_step_with_retries(
792
+ tel_model,
793
+ site_model,
794
+ args_dict,
795
+ current_params,
796
+ current_metric,
797
+ data_to_plot,
798
+ radius,
799
+ learning_rate,
800
+ )
801
+ (
802
+ new_params,
803
+ new_d80,
804
+ new_metric,
805
+ new_p_value,
806
+ new_simulated_data,
807
+ step_accepted,
808
+ learning_rate,
809
+ ) = step_result
810
+
811
+ if not step_accepted or new_params is None:
812
+ learning_rate *= 2.0
813
+ logger.info(f"No step accepted, increasing learning rate to {learning_rate:.6f}")
814
+ continue
587
815
 
588
- plt.title(
589
- f"PSF D80 vs Off-axis Angle - {tel_model.name}\n"
590
- f"Best Parameters: \n"
591
- f"reflection=[{best_pars['mirror_reflection_random_angle'][0]:.4f},"
592
- f"{best_pars['mirror_reflection_random_angle'][1]:.4f},"
593
- f"{best_pars['mirror_reflection_random_angle'][2]:.4f}],\n"
594
- f"align_horizontal={best_pars['mirror_align_random_horizontal'][0]:.4f}\n"
595
- f"align_vertical={best_pars['mirror_align_random_vertical'][0]:.4f}\n"
816
+ # Step was accepted - update state
817
+ current_params, current_metric, current_d80 = new_params, new_metric, new_d80
818
+ results.append(
819
+ (current_params.copy(), current_metric, None, current_d80, new_simulated_data)
596
820
  )
597
- plt.xlabel("Off-axis Angle (degrees)")
598
- plt.ylabel("D80 (cm)" if key == "d80_cm" else "D80 (degrees)")
599
- plt.ylim(bottom=0)
600
- plt.xticks(rotation=45)
601
- plt.xlim(0, max_offset)
602
- plt.grid(True, alpha=0.3)
603
821
 
604
- plot_file_name = f"tune_psf_{tel_model.name}_best_params_{key}.pdf"
605
- plot_file = output_dir.joinpath(plot_file_name)
606
- visualize.save_figure(plt, plot_file, log_title=f"D80 vs off-axis ({key})")
822
+ if current_metric < best_metric:
823
+ best_metric, best_params, best_d80 = current_metric, current_params.copy(), current_d80
824
+
825
+ _create_step_plot(
826
+ pdf_pages,
827
+ args_dict,
828
+ data_to_plot,
829
+ current_params,
830
+ new_d80,
831
+ new_metric,
832
+ new_p_value,
833
+ new_simulated_data,
834
+ )
835
+ logger.info(f" Accepted step: improved to {new_metric:.6f}")
607
836
 
608
- plt.close("all")
837
+ _create_final_plot(
838
+ pdf_pages, tel_model, site_model, args_dict, best_params, data_to_plot, radius, best_d80
839
+ )
840
+ return best_params, best_d80, results
609
841
 
610
842
 
611
- def write_tested_parameters_to_file(results, best_pars, best_d80, output_dir, tel_model):
843
+ def _write_log_interpretation(f, use_ks_statistic):
844
+ """Write interpretation section for the log file."""
845
+ if use_ks_statistic:
846
+ f.write(
847
+ "P-VALUE INTERPRETATION:\n p > 0.05: Distributions are statistically similar "
848
+ "(good fit)\n"
849
+ " p < 0.05: Distributions are significantly different (poor fit)\n"
850
+ " p < 0.01: Very significant difference (very poor fit)\n\n"
851
+ )
852
+ else:
853
+ f.write(
854
+ "RMSD INTERPRETATION:\n Lower RMSD values indicate better agreement between "
855
+ "measured and simulated PSF curves\n\n"
856
+ )
857
+
858
+
859
+ def _write_iteration_entry(
860
+ f, iteration, pars, metric, p_value, d80, use_ks_statistic, metric_name, total_iterations
861
+ ):
862
+ """Write a single iteration entry."""
863
+ status = "FINAL" if iteration == total_iterations - 1 else f"ITER-{iteration:02d}"
864
+
865
+ if use_ks_statistic and p_value is not None:
866
+ significance = plot_psf.get_significance_label(p_value)
867
+ f.write(
868
+ f"[{status}] Iteration {iteration}: KS_stat={metric:.6f}, "
869
+ f"p_value={p_value:.6f} ({significance}), D80={d80:.6f} cm\n"
870
+ )
871
+ else:
872
+ f.write(f"[{status}] Iteration {iteration}: {metric_name}={metric:.6f}, D80={d80:.6f} cm\n")
873
+
874
+ for par, value in pars.items():
875
+ f.write(f" {par}: {_create_log_header_and_format_value(None, None, None, value)}\n")
876
+ f.write("\n")
877
+
878
+
879
+ def _write_optimization_summary(f, gd_results, best_pars, best_d80, metric_name):
880
+ """Write optimization summary section."""
881
+ f.write("OPTIMIZATION SUMMARY:\n")
882
+ best_metric_from_results = min(metric for _, metric, _, _, _ in gd_results)
883
+ f.write(f"Best {metric_name.lower()}: {best_metric_from_results:.6f}\n")
884
+ f.write(f"Best D80: {best_d80:.6f} cm\n" if best_d80 is not None else "Best D80: N/A\n")
885
+ f.write(f"Total iterations: {len(gd_results)}\n\nFINAL OPTIMIZED PARAMETERS:\n")
886
+ for par, value in best_pars.items():
887
+ f.write(f"{par}: {_create_log_header_and_format_value(None, None, None, value)}\n")
888
+
889
+
890
+ def write_gradient_descent_log(
891
+ gd_results, best_pars, best_d80, output_dir, tel_model, use_ks_statistic=False
892
+ ):
612
893
  """
613
- Write all tested parameters and their metrics to a text file.
894
+ Write gradient descent optimization progression to a log file.
614
895
 
615
896
  Parameters
616
897
  ----------
617
- results : list
618
- List of (pars, rmsd, d80, simulated_data) tuples
898
+ gd_results : list
899
+ List of tuples containing (params, metric, p_value, d80, simulated_data)
900
+ for each optimization iteration.
619
901
  best_pars : dict
620
- Best parameter set
902
+ Dictionary containing the best parameter values found.
621
903
  best_d80 : float
622
- Best D80 value
904
+ D80 diameter in cm for the best parameter set.
623
905
  output_dir : Path
624
- Output directory path
906
+ Directory where the log file will be written.
625
907
  tel_model : TelescopeModel
626
- Telescope model object for filename generation
908
+ Telescope model object for naming the output file.
909
+ use_ks_statistic : bool, optional
910
+ If True, log KS statistic values; if False, log RMSD values (default: False).
911
+
912
+ Returns
913
+ -------
914
+ Path
915
+ Path to the created log file.
627
916
  """
628
- param_file = output_dir.joinpath(f"psf_optimization_{tel_model.name}.log")
917
+ metric_name = "KS Statistic" if use_ks_statistic else "RMSD"
918
+ file_suffix = "ks" if use_ks_statistic else "rmsd"
919
+ param_file = output_dir.joinpath(f"psf_gradient_descent_{file_suffix}_{tel_model.name}.log")
920
+
629
921
  with open(param_file, "w", encoding="utf-8") as f:
630
- f.write("# PSF Parameter Optimization Log\n")
631
- f.write(f"# Telescope: {tel_model.name}\n")
632
- f.write(f"# Total parameter sets tested: {len(results)}\n")
633
- f.write("#" + "=" * 60 + "\n\n")
922
+ header = _create_log_header_and_format_value(
923
+ f"PSF Parameter Optimization - Gradient Descent Progression ({metric_name})",
924
+ tel_model,
925
+ {"Total iterations": len(gd_results)},
926
+ )
927
+ f.write(header)
634
928
 
635
- f.write("PARAMETER TESTING RESULTS:\n")
636
- for i, (pars, rmsd, d80, _) in enumerate(results):
637
- is_best = pars is best_pars
638
- status = "BEST" if is_best else "TESTED"
639
- f.write(f"[{status}] Set {i + 1:03d}: RMSD={rmsd:.5f}, D80={d80:.5f} cm\n")
640
- for par, value in pars.items():
641
- f.write(f" {par}: {value}\n")
642
- f.write("\n")
929
+ f.write(
930
+ "GRADIENT DESCENT PROGRESSION:\n(Each entry shows the parameters chosen "
931
+ "at each iteration)\n\n"
932
+ )
933
+ _write_log_interpretation(f, use_ks_statistic)
934
+
935
+ for iteration, (pars, metric, p_value, d80, _) in enumerate(gd_results):
936
+ _write_iteration_entry(
937
+ f,
938
+ iteration,
939
+ pars,
940
+ metric,
941
+ p_value,
942
+ d80,
943
+ use_ks_statistic,
944
+ metric_name,
945
+ len(gd_results),
946
+ )
947
+
948
+ _write_optimization_summary(f, gd_results, best_pars, best_d80, metric_name)
643
949
 
644
- f.write("OPTIMIZATION SUMMARY:\n")
645
- f.write(f"Best RMSD: {min(result[1] for result in results):.5f}\n")
646
- f.write(f"Best D80: {best_d80:.5f} cm\n")
647
- f.write("\nOPTIMIZED PARAMETERS:\n")
648
- for par, value in best_pars.items():
649
- f.write(f"{par}: {value}\n")
650
950
  return param_file
651
951
 
652
952
 
653
- def _add_units_to_psf_parameters(best_pars):
953
+ def analyze_monte_carlo_error(
954
+ tel_model, site_model, args_dict, data_to_plot, radius, n_simulations=500
955
+ ):
654
956
  """
655
- Add proper astropy units to PSF parameters based on their schemas.
957
+ Analyze Monte Carlo uncertainty in PSF optimization metrics.
958
+
959
+ Runs multiple simulations with the same parameters to quantify the
960
+ statistical uncertainty in the optimization metric due to Monte Carlo
961
+ noise in the ray tracing simulations. Returns None values if no
962
+ measurement data is provided or all simulations fail.
656
963
 
657
964
  Parameters
658
965
  ----------
659
- best_pars : dict
660
- Dictionary with PSF parameter names as keys and values as lists
966
+ tel_model : TelescopeModel
967
+ Telescope model object with current parameter configuration.
968
+ site_model : SiteModel
969
+ Site model object with environmental conditions.
970
+ args_dict : dict
971
+ Dictionary containing simulation configuration arguments.
972
+ data_to_plot : dict
973
+ Dictionary containing measured PSF data under "measured" key.
974
+ radius : array-like
975
+ Radius values in cm for PSF evaluation.
976
+ n_simulations : int, optional
977
+ Number of Monte Carlo simulations to run (default: 500).
661
978
 
662
979
  Returns
663
980
  -------
664
- dict
665
- Dictionary with same keys but values converted to astropy quantities with units
981
+ tuple of (float, float, list, float, float, list, float, float, list)
982
+ - mean_metric: Mean RMSD or KS statistic value
983
+ - std_metric: Standard deviation of metric values
984
+ - metric_values: List of all metric values from simulations
985
+ - mean_p_value: Mean p-value (None if using RMSD)
986
+ - std_p_value: Standard deviation of p-values (None if using RMSD)
987
+ - p_values: List of all p-values from simulations
988
+ - mean_d80: Mean D80 diameter in cm
989
+ - std_d80: Standard deviation of D80 values
990
+ - d80_values: List of all D80 values from simulations
666
991
  """
667
- psf_pars_with_units = {}
992
+ if data_to_plot is None or radius is None:
993
+ logger.error("No PSF measurement data provided. Cannot analyze Monte Carlo error.")
994
+ return None, None, []
668
995
 
669
- for param_name, param_values in best_pars.items():
670
- if param_name == "mirror_reflection_random_angle":
671
- psf_pars_with_units[param_name] = [
672
- param_values[0] * u.deg,
673
- param_values[1] * u.dimensionless_unscaled,
674
- param_values[2] * u.deg,
675
- ]
676
- elif param_name in ["mirror_align_random_horizontal", "mirror_align_random_vertical"]:
677
- psf_pars_with_units[param_name] = [
678
- param_values[0] * u.deg,
679
- param_values[1] * u.deg,
680
- param_values[2] * u.dimensionless_unscaled,
681
- param_values[3] * u.dimensionless_unscaled,
682
- ]
683
- else:
684
- psf_pars_with_units[param_name] = param_values
996
+ initial_params = get_previous_values(tel_model)
997
+ for param_name, param_values in initial_params.items():
998
+ logger.info(f" {param_name}: {param_values}")
685
999
 
686
- return psf_pars_with_units
1000
+ use_ks_statistic = args_dict.get("ks_statistic", False)
1001
+ metric_values, p_values, d80_values = [], [], []
687
1002
 
1003
+ for i in range(n_simulations):
1004
+ try:
1005
+ d80, metric, p_value, _ = run_psf_simulation(
1006
+ tel_model,
1007
+ site_model,
1008
+ args_dict,
1009
+ initial_params,
1010
+ data_to_plot,
1011
+ radius,
1012
+ use_ks_statistic=use_ks_statistic,
1013
+ )
1014
+ metric_values.append(metric)
1015
+ d80_values.append(d80)
1016
+ p_values.append(p_value)
1017
+ except (ValueError, RuntimeError) as e:
1018
+ logger.warning(f"WARNING: Simulation {i + 1} failed: {e}")
688
1019
 
689
- def export_psf_parameters(best_pars, tel_model, parameter_version, output_dir):
690
- """
691
- Export PSF parameters as simulation model parameter files.
1020
+ if not metric_values:
1021
+ logger.error("All Monte Carlo simulations failed.")
1022
+ return None, None, [], None, None, []
692
1023
 
693
- Parameters
694
- ----------
695
- best_pars : dict
696
- Best parameter set
697
- tel_model : TelescopeModel
698
- Telescope model object
699
- parameter_version : str
700
- Parameter version string
701
- output_dir : Path
702
- Output directory path
703
- """
704
- try:
705
- logger.info("Exporting best PSF parameters as simulation model parameter files")
706
- psf_pars_with_units = _add_units_to_psf_parameters(best_pars)
707
- parameter_output_path = output_dir / tel_model.name
708
- for parameter_name, parameter_value in psf_pars_with_units.items():
709
- writer.ModelDataWriter.dump_model_parameter(
710
- parameter_name=parameter_name,
711
- value=parameter_value,
712
- instrument=tel_model.name,
713
- parameter_version=parameter_version,
714
- output_file=f"{parameter_name}-{parameter_version}.json",
715
- output_path=parameter_output_path,
716
- use_plain_output_path=True,
717
- )
718
- logger.info(f"simulation model parameter files exported to {output_dir}")
719
- except ImportError as e:
720
- logger.warning(f"Could not export simulation parameters: {e}")
721
- except (ValueError, KeyError, OSError) as e:
722
- logger.error(f"Error exporting simulation parameters: {e}")
1024
+ mean_metric, std_metric = np.mean(metric_values), np.std(metric_values, ddof=1)
1025
+ mean_d80, std_d80 = np.mean(d80_values), np.std(d80_values, ddof=1)
723
1026
 
1027
+ if use_ks_statistic:
1028
+ valid_p_values = [p for p in p_values if p is not None]
1029
+ mean_p_value = np.mean(valid_p_values) if valid_p_values else None
1030
+ std_p_value = np.std(valid_p_values, ddof=1) if valid_p_values else None
1031
+ else:
1032
+ mean_p_value = std_p_value = None
1033
+
1034
+ return (
1035
+ mean_metric,
1036
+ std_metric,
1037
+ metric_values,
1038
+ mean_p_value,
1039
+ std_p_value,
1040
+ p_values,
1041
+ mean_d80,
1042
+ std_d80,
1043
+ d80_values,
1044
+ )
724
1045
 
725
- def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
726
- """
727
- Run the complete PSF parameter optimization workflow.
728
1046
 
729
- This function consolidates the main optimization logic to make the application lighter.
1047
+ def write_monte_carlo_analysis(mc_results, output_dir, tel_model, use_ks_statistic=False):
1048
+ """
1049
+ Write Monte Carlo uncertainty analysis results to a log file.
730
1050
 
731
1051
  Parameters
732
1052
  ----------
733
- tel_model : TelescopeModel
734
- Telescope model object
735
- site_model : SiteModel
736
- Site model object
737
- args_dict : dict
738
- Dictionary containing parsed command-line arguments
1053
+ mc_results : tuple
1054
+ Tuple of Monte Carlo analysis results from analyze_monte_carlo_error().
739
1055
  output_dir : Path
740
- Output directory path
1056
+ Directory where the log file will be written.
1057
+ tel_model : TelescopeModel
1058
+ Telescope model object for naming the output file.
1059
+ use_ks_statistic : bool, optional
1060
+ If True, analyze KS statistic results; if False, analyze RMSD results (default: False).
741
1061
 
742
1062
  Returns
743
1063
  -------
744
- None
745
- All results are saved to files and printed to console
1064
+ Path
1065
+ Path to the created log file.
746
1066
  """
747
- # Generate parameter sets
748
- all_parameters = []
749
- mrra_0, mfr_0, mrra2_0, mar_0 = get_previous_values(tel_model)
1067
+ (
1068
+ mean_metric,
1069
+ std_metric,
1070
+ metric_values,
1071
+ mean_p_value,
1072
+ std_p_value,
1073
+ p_values,
1074
+ mean_d80,
1075
+ std_d80,
1076
+ d80_values,
1077
+ ) = mc_results
1078
+
1079
+ metric_name = "KS Statistic" if use_ks_statistic else "RMSD"
1080
+ file_suffix = "ks" if use_ks_statistic else "rmsd"
1081
+ mc_file = output_dir.joinpath(f"monte_carlo_{file_suffix}_analysis_{tel_model.name}.log")
1082
+
1083
+ with open(mc_file, "w", encoding="utf-8") as f:
1084
+ header = _create_log_header_and_format_value(
1085
+ f"Monte Carlo {metric_name} Error Analysis",
1086
+ tel_model,
1087
+ {"Number of simulations": len(metric_values)},
1088
+ )
1089
+ f.write(header)
750
1090
 
751
- n_runs = args_dict.get("n_runs")
752
- generate_random_parameters(
753
- all_parameters, n_runs, args_dict, mrra_0, mfr_0, mrra2_0, mar_0, tel_model
754
- )
1091
+ f.write(
1092
+ f"MONTE CARLO SIMULATION RESULTS:\nNumber of successful simulations: "
1093
+ f"{len(metric_values)}\n\n"
1094
+ )
1095
+ f.write(f"{metric_name.upper()} STATISTICS:\n")
1096
+ f.write(
1097
+ f"Mean {metric_name.lower()}: {mean_metric:.6f}\n"
1098
+ f"Standard deviation: {std_metric:.6f}\n"
1099
+ f"Minimum {metric_name.lower()}: {min(metric_values):.6f}\n"
1100
+ f"Maximum {metric_name.lower()}: {max(metric_values):.6f}\n"
1101
+ f"Relative error: {(std_metric / mean_metric) * 100:.2f}%\n\n"
1102
+ )
755
1103
 
1104
+ if use_ks_statistic and mean_p_value is not None:
1105
+ valid_p_values = [p for p in p_values if p is not None]
1106
+ f.write(
1107
+ f"P-VALUE STATISTICS:\nMean p-value: {mean_p_value:.6f}\n"
1108
+ f"Standard deviation: {std_p_value:.6f}\n"
1109
+ f"Minimum p-value: {min(valid_p_values):.6f}\n"
1110
+ f"Maximum p-value: {max(valid_p_values):.6f}\n"
1111
+ f"Relative error: {(std_p_value / mean_p_value) * 100:.2f}%\n"
1112
+ )
1113
+
1114
+ good_fits = sum(1 for p in valid_p_values if p > 0.05)
1115
+ fair_fits = sum(1 for p in valid_p_values if 0.01 < p <= 0.05)
1116
+ poor_fits = sum(1 for p in valid_p_values if p <= 0.01)
1117
+ f.write(
1118
+ f"Good fits (p > 0.05): {good_fits}/{len(valid_p_values)} "
1119
+ f"({100 * good_fits / len(valid_p_values):.1f}%)\n"
1120
+ f"Fair fits (0.01 < p <= 0.05): {fair_fits}/{len(valid_p_values)} "
1121
+ f"({100 * fair_fits / len(valid_p_values):.1f}%)\n"
1122
+ f"Poor fits (p <= 0.01): {poor_fits}/{len(valid_p_values)} "
1123
+ f"({100 * poor_fits / len(valid_p_values):.1f}%)\n\n"
1124
+ )
1125
+
1126
+ f.write(
1127
+ f"D80 STATISTICS:\nMean D80: {mean_d80:.6f} cm\n"
1128
+ f"Standard deviation: {std_d80:.6f} cm\n"
1129
+ f"Minimum D80: {min(d80_values):.6f} cm\n"
1130
+ f"Maximum D80: {max(d80_values):.6f} cm\n"
1131
+ f"Relative error: {(std_d80 / mean_d80) * 100:.2f}%\n\n"
1132
+ )
1133
+
1134
+ f.write("INDIVIDUAL SIMULATION RESULTS:\n")
1135
+ for i, (metric_val, p_value, d80) in enumerate(zip(metric_values, p_values, d80_values)):
1136
+ if use_ks_statistic and p_value is not None:
1137
+ if p_value > 0.05:
1138
+ significance = "GOOD"
1139
+ elif p_value > 0.01:
1140
+ significance = "FAIR"
1141
+ else:
1142
+ significance = "POOR"
1143
+ f.write(
1144
+ f"Simulation {i + 1:2d}: {metric_name}={metric_val:.6f}, "
1145
+ f"p_value={p_value:.6f} ({significance}), D80={d80:.6f} cm\n"
1146
+ )
1147
+ else:
1148
+ f.write(
1149
+ f"Simulation {i + 1:2d}: {metric_name}={metric_val:.6f}, D80={d80:.6f} cm\n"
1150
+ )
1151
+
1152
+ return mc_file
1153
+
1154
+
1155
+ def _handle_monte_carlo_analysis(
1156
+ tel_model, site_model, args_dict, data_to_plot, radius, output_dir, use_ks_statistic
1157
+ ):
1158
+ """Handle Monte Carlo analysis if requested."""
1159
+ if not args_dict.get("monte_carlo_analysis", False):
1160
+ return False
1161
+
1162
+ mc_results = analyze_monte_carlo_error(tel_model, site_model, args_dict, data_to_plot, radius)
1163
+ if mc_results[0] is not None:
1164
+ mc_file = write_monte_carlo_analysis(mc_results, output_dir, tel_model, use_ks_statistic)
1165
+ logger.info(f"Monte Carlo analysis results written to {mc_file}")
1166
+ mc_plot_file = output_dir.joinpath(f"monte_carlo_uncertainty_{tel_model.name}.pdf")
1167
+ plot_psf.create_monte_carlo_uncertainty_plot(mc_results, mc_plot_file, use_ks_statistic)
1168
+ return True
1169
+
1170
+
1171
+ def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
1172
+ """Run the complete PSF parameter optimization workflow using gradient descent."""
756
1173
  data_to_plot, radius = load_and_process_data(args_dict)
1174
+ use_ks_statistic = args_dict.get("ks_statistic", False)
757
1175
 
758
- # Preparing figure name and PDF pages for plotting
759
- plot_file_name = "_".join(("tune_psf", tel_model.name + ".pdf"))
760
- plot_file = output_dir.joinpath(plot_file_name)
761
- pdf_pages = PdfPages(plot_file)
1176
+ if _handle_monte_carlo_analysis(
1177
+ tel_model, site_model, args_dict, data_to_plot, radius, output_dir, use_ks_statistic
1178
+ ):
1179
+ return
762
1180
 
763
- # Find best parameters
764
- best_pars, best_d80, results = find_best_parameters(
765
- all_parameters, tel_model, site_model, args_dict, data_to_plot, radius, pdf_pages
1181
+ # Run gradient descent optimization
1182
+ threshold = args_dict.get("rmsd_threshold")
1183
+ learning_rate = args_dict.get("learning_rate")
1184
+
1185
+ best_pars, best_d80, gd_results = run_gradient_descent_optimization(
1186
+ tel_model,
1187
+ site_model,
1188
+ args_dict,
1189
+ data_to_plot,
1190
+ radius,
1191
+ rmsd_threshold=threshold,
1192
+ learning_rate=learning_rate,
1193
+ output_dir=output_dir,
766
1194
  )
767
1195
 
768
- plt.close()
769
- pdf_pages.close()
1196
+ # Check if optimization was successful
1197
+ if not gd_results or best_pars is None:
1198
+ logger.error("Gradient descent optimization failed. No valid results found.")
1199
+ if radius is None:
1200
+ logger.error(
1201
+ "Possible cause: No PSF measurement data provided. "
1202
+ "Use --data argument to provide PSF data."
1203
+ )
1204
+ return
1205
+
1206
+ plot_psf.create_optimization_plots(args_dict, gd_results, tel_model, data_to_plot, output_dir)
770
1207
 
771
- # Write all tested parameters and their metrics to a file
772
- param_file = write_tested_parameters_to_file(
773
- results, best_pars, best_d80, output_dir, tel_model
1208
+ convergence_plot_file = output_dir.joinpath(
1209
+ f"gradient_descent_convergence_{tel_model.name}.png"
1210
+ )
1211
+ plot_psf.create_gradient_descent_convergence_plot(
1212
+ gd_results, threshold, convergence_plot_file, use_ks_statistic
774
1213
  )
775
- print(f"\nParameter results written to {param_file}")
776
1214
 
777
- # Automatically create D80 vs off-axis angle plot for best parameters
778
- create_d80_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, output_dir)
779
- print("D80 vs off-axis angle plots created successfully")
1215
+ param_file = write_gradient_descent_log(
1216
+ gd_results, best_pars, best_d80, output_dir, tel_model, use_ks_statistic
1217
+ )
1218
+ logger.info(f"\nGradient descent progression written to {param_file}")
780
1219
 
781
- print("\nBest parameters:")
782
- for par, value in best_pars.items():
783
- print(f"{par} = {value}")
1220
+ plot_psf.create_d80_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, output_dir)
784
1221
 
785
- # Export best parameters as simulation model parameter files (if flag is provided)
786
1222
  if args_dict.get("write_psf_parameters", False):
1223
+ logger.info("Exporting best parameters as model files...")
787
1224
  export_psf_parameters(
788
- best_pars,
789
- tel_model,
790
- args_dict.get("parameter_version", "0.0.0"),
791
- output_dir.parent,
1225
+ best_pars, args_dict.get("telescope"), args_dict.get("parameter_version"), output_dir
792
1226
  )