gammasimtools 0.24.0__py3-none-any.whl → 0.25.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 (59) hide show
  1. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/METADATA +1 -1
  2. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/RECORD +58 -55
  3. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/entry_points.txt +1 -0
  4. simtools/_version.py +2 -2
  5. simtools/application_control.py +50 -0
  6. simtools/applications/derive_psf_parameters.py +5 -0
  7. simtools/applications/derive_pulse_shape_parameters.py +195 -0
  8. simtools/applications/plot_array_layout.py +63 -1
  9. simtools/applications/simulate_flasher.py +3 -2
  10. simtools/applications/simulate_pedestals.py +1 -1
  11. simtools/applications/simulate_prod.py +8 -23
  12. simtools/applications/simulate_prod_htcondor_generator.py +7 -0
  13. simtools/applications/submit_array_layouts.py +5 -3
  14. simtools/applications/validate_file_using_schema.py +49 -123
  15. simtools/configuration/commandline_parser.py +8 -6
  16. simtools/corsika/corsika_config.py +197 -87
  17. simtools/data_model/model_data_writer.py +14 -2
  18. simtools/data_model/schema.py +112 -5
  19. simtools/data_model/validate_data.py +82 -48
  20. simtools/db/db_model_upload.py +2 -1
  21. simtools/db/mongo_db.py +133 -42
  22. simtools/dependencies.py +5 -9
  23. simtools/io/eventio_handler.py +128 -0
  24. simtools/job_execution/htcondor_script_generator.py +0 -2
  25. simtools/layout/array_layout_utils.py +1 -1
  26. simtools/model/array_model.py +36 -5
  27. simtools/model/model_parameter.py +0 -1
  28. simtools/model/model_repository.py +18 -5
  29. simtools/ray_tracing/psf_analysis.py +11 -8
  30. simtools/ray_tracing/psf_parameter_optimisation.py +822 -679
  31. simtools/reporting/docs_read_parameters.py +69 -9
  32. simtools/runners/corsika_runner.py +12 -3
  33. simtools/runners/corsika_simtel_runner.py +6 -0
  34. simtools/runners/runner_services.py +17 -7
  35. simtools/runners/simtel_runner.py +12 -54
  36. simtools/schemas/model_parameters/flasher_pulse_exp_decay.schema.yml +2 -0
  37. simtools/schemas/model_parameters/flasher_pulse_shape.schema.yml +50 -0
  38. simtools/schemas/model_parameters/flasher_pulse_width.schema.yml +2 -0
  39. simtools/schemas/simulation_models_info.schema.yml +2 -0
  40. simtools/simtel/pulse_shapes.py +268 -0
  41. simtools/simtel/simtel_config_writer.py +82 -1
  42. simtools/simtel/simtel_io_event_writer.py +2 -2
  43. simtools/simtel/simulator_array.py +58 -12
  44. simtools/simtel/simulator_light_emission.py +45 -8
  45. simtools/simulator.py +361 -347
  46. simtools/testing/assertions.py +62 -6
  47. simtools/testing/configuration.py +1 -1
  48. simtools/testing/log_inspector.py +4 -1
  49. simtools/testing/sim_telarray_metadata.py +1 -1
  50. simtools/testing/validate_output.py +44 -9
  51. simtools/utils/names.py +2 -4
  52. simtools/version.py +37 -0
  53. simtools/visualization/legend_handlers.py +14 -4
  54. simtools/visualization/plot_array_layout.py +229 -33
  55. simtools/visualization/plot_mirrors.py +837 -0
  56. simtools/simtel/simtel_io_file_info.py +0 -62
  57. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/WHEEL +0 -0
  58. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/licenses/LICENSE +0 -0
  59. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/top_level.txt +0 -0
@@ -11,6 +11,7 @@ to quantify the difference between measured and simulated PSF curves.
11
11
 
12
12
  import logging
13
13
  from collections import OrderedDict
14
+ from dataclasses import dataclass
14
15
 
15
16
  import astropy.units as u
16
17
  import numpy as np
@@ -20,6 +21,7 @@ from scipy import stats
20
21
  from simtools.data_model import model_data_writer as writer
21
22
  from simtools.ray_tracing.ray_tracing import RayTracing
22
23
  from simtools.utils import general as gen
24
+ from simtools.utils import names
23
25
  from simtools.visualization import plot_psf
24
26
  from simtools.visualization.plot_psf import DEFAULT_FRACTION, get_psf_diameter_label
25
27
 
@@ -32,6 +34,766 @@ CUMULATIVE_PSF = "Cumulative PSF"
32
34
  KS_STATISTIC_NAME = "KS statistic"
33
35
 
34
36
 
37
+ @dataclass
38
+ class GradientStepResult:
39
+ """
40
+ Result from a gradient descent step attempt.
41
+
42
+ Attributes
43
+ ----------
44
+ params : dict or None
45
+ Updated parameter values, None if step failed.
46
+ psf_diameter : float or None
47
+ PSF containment diameter in cm, None if step failed.
48
+ metric : float or None
49
+ Optimization metric value (RMSD or KS statistic), None if step failed.
50
+ p_value : float or None
51
+ P-value from KS test (None if using RMSD or if step failed).
52
+ simulated_data : numpy.ndarray or None
53
+ Simulated PSF data, None if step failed.
54
+ step_accepted : bool
55
+ True if the step improved the metric, False otherwise.
56
+ learning_rate : float
57
+ Final learning rate after adjustments.
58
+ """
59
+
60
+ params: dict | None
61
+ psf_diameter: float | None
62
+ metric: float | None
63
+ p_value: float | None
64
+ simulated_data: np.ndarray | None
65
+ step_accepted: bool
66
+ learning_rate: float
67
+
68
+
69
+ class PSFParameterOptimizer:
70
+ """
71
+ Gradient descent optimizer for PSF parameters.
72
+
73
+ Parameters
74
+ ----------
75
+ tel_model : TelescopeModel
76
+ Telescope model object containing parameter configurations.
77
+ site_model : SiteModel
78
+ Site model object with environmental conditions.
79
+ args_dict : dict
80
+ Dictionary containing simulation configuration arguments.
81
+ data_to_plot : dict
82
+ Dictionary containing measured PSF data under "measured" key.
83
+ radius : numpy.ndarray
84
+ Radius values in cm for PSF evaluation.
85
+ output_dir : Path
86
+ Directory for saving optimization results and plots.
87
+
88
+ Attributes
89
+ ----------
90
+ simulation_cache : dict
91
+ Cache for simulation results to avoid redundant ray tracing simulations.
92
+ Key: frozenset of (param_name, tuple(values)) items
93
+ Value: (psf_diameter, metric, p_value, simulated_data)
94
+ """
95
+
96
+ # Learning rate adjustment constants
97
+ LR_REDUCTION_FACTOR = 0.7
98
+ LR_INCREASE_FACTOR = 2.0
99
+ LR_MINIMUM_THRESHOLD = 1e-6
100
+ LR_MAXIMUM_THRESHOLD = 0.01
101
+ LR_RESET_VALUE = 0.0001
102
+
103
+ def __init__(self, tel_model, site_model, args_dict, data_to_plot, radius, output_dir):
104
+ """Initialize the PSF parameter optimizer."""
105
+ self.tel_model = tel_model
106
+ self.site_model = site_model
107
+ self.args_dict = args_dict
108
+ self.data_to_plot = data_to_plot
109
+ self.radius = radius
110
+ self.output_dir = output_dir
111
+ self.use_ks_statistic = args_dict.get("ks_statistic", False)
112
+ self.fraction = args_dict.get("fraction", DEFAULT_FRACTION)
113
+ self.simulation_cache = {}
114
+ self.cache_hits = 0
115
+ self.cache_misses = 0
116
+
117
+ def _params_to_cache_key(self, params):
118
+ """Convert parameters dict to a hashable cache key."""
119
+ items = []
120
+ for key in sorted(params.keys()):
121
+ value = params[key]
122
+ if isinstance(value, list):
123
+ items.append((key, tuple(value)))
124
+ else:
125
+ items.append((key, value))
126
+ return frozenset(items)
127
+
128
+ def _reduce_learning_rate(self, current_lr):
129
+ """
130
+ Reduce learning rate with minimum threshold and reset.
131
+
132
+ Parameters
133
+ ----------
134
+ current_lr : float
135
+ Current learning rate.
136
+
137
+ Returns
138
+ -------
139
+ float
140
+ Reduced learning rate, reset to LR_RESET_VALUE if below threshold.
141
+ """
142
+ new_lr = current_lr * self.LR_REDUCTION_FACTOR
143
+ if new_lr < self.LR_MINIMUM_THRESHOLD:
144
+ return self.LR_RESET_VALUE
145
+ return new_lr
146
+
147
+ def _increase_learning_rate(self, current_lr):
148
+ """
149
+ Increase learning rate.
150
+
151
+ Parameters
152
+ ----------
153
+ current_lr : float
154
+ Current learning rate.
155
+
156
+ Returns
157
+ -------
158
+ float
159
+ Increased learning rate, capped at LR_MAXIMUM_THRESHOLD.
160
+ """
161
+ new_lr = current_lr * self.LR_INCREASE_FACTOR
162
+ return min(new_lr, self.LR_MAXIMUM_THRESHOLD)
163
+
164
+ def get_initial_parameters(self):
165
+ """
166
+ Get current PSF parameter values from the telescope model.
167
+
168
+ Returns
169
+ -------
170
+ dict
171
+ Dictionary of current parameter values.
172
+ """
173
+ return get_previous_values(self.tel_model)
174
+
175
+ def run_simulation(
176
+ self, pars, pdf_pages=None, is_best=False, use_cache=True, use_ks_statistic=None
177
+ ):
178
+ """
179
+ Run PSF simulation for given parameters with optional caching.
180
+
181
+ Parameters
182
+ ----------
183
+ pars : dict
184
+ Dictionary of parameter values to test.
185
+ pdf_pages : PdfPages, optional
186
+ PDF pages object for saving plots.
187
+ is_best : bool, optional
188
+ Flag indicating if this is the best parameter set.
189
+ use_cache : bool, optional
190
+ If True, use cached results if available.
191
+ use_ks_statistic : bool, optional
192
+ If provided, override self.use_ks_statistic for this simulation.
193
+
194
+ Returns
195
+ -------
196
+ tuple
197
+ (psf_diameter, metric, p_value, simulated_data)
198
+ """
199
+ # Determine which statistic to use
200
+ ks_stat = use_ks_statistic if use_ks_statistic is not None else self.use_ks_statistic
201
+
202
+ if use_cache and pdf_pages is None and not is_best:
203
+ cache_key = self._params_to_cache_key(pars)
204
+ if cache_key in self.simulation_cache:
205
+ self.cache_hits += 1
206
+ return self.simulation_cache[cache_key]
207
+ self.cache_misses += 1
208
+
209
+ result = run_psf_simulation(
210
+ self.tel_model,
211
+ self.site_model,
212
+ self.args_dict,
213
+ pars,
214
+ self.data_to_plot,
215
+ self.radius,
216
+ pdf_pages,
217
+ is_best,
218
+ ks_stat,
219
+ )
220
+
221
+ # Cache the result if caching is enabled and not plotting
222
+ if use_cache and pdf_pages is None and not is_best:
223
+ cache_key = self._params_to_cache_key(pars)
224
+ self.simulation_cache[cache_key] = result
225
+
226
+ return result
227
+
228
+ def calculate_gradient(self, current_params, current_metric, epsilon=0.0005):
229
+ """
230
+ Calculate numerical gradients for all optimization parameters.
231
+
232
+ Parameters
233
+ ----------
234
+ current_params : dict
235
+ Dictionary of current parameter values.
236
+ current_metric : float
237
+ Current RMSD or KS statistic value.
238
+ epsilon : float, optional
239
+ Perturbation value for finite difference calculation.
240
+
241
+ Returns
242
+ -------
243
+ dict or None
244
+ Dictionary mapping parameter names to their gradient values.
245
+ Returns None if gradient calculation fails for any parameter.
246
+ """
247
+ gradients = {}
248
+
249
+ for param_name, param_values in current_params.items():
250
+ param_gradient = self._calculate_param_gradient(
251
+ current_params,
252
+ current_metric,
253
+ param_name,
254
+ param_values,
255
+ epsilon,
256
+ )
257
+ if param_gradient is None:
258
+ return None
259
+ gradients[param_name] = param_gradient
260
+
261
+ return gradients
262
+
263
+ def apply_gradient_step(self, current_params, gradients, learning_rate):
264
+ """
265
+ Apply gradient descent step to update parameters while preserving constraints.
266
+
267
+ This function applies the standard gradient descent update and preserves
268
+ zenith angle components (index 1) for mirror alignment parameters.
269
+
270
+ Note: Use _are_all_parameters_within_allowed_range() to validate the result before
271
+ accepting the step.
272
+
273
+ Parameters
274
+ ----------
275
+ current_params : dict
276
+ Dictionary of current parameter values.
277
+ gradients : dict
278
+ Dictionary of gradient values for each parameter.
279
+ learning_rate : float
280
+ Step size for the gradient descent update.
281
+
282
+ Returns
283
+ -------
284
+ dict
285
+ Dictionary of updated parameter values after applying the gradient step.
286
+ """
287
+ new_params = {}
288
+ for param_name, param_values in current_params.items():
289
+ param_gradients = gradients[param_name]
290
+
291
+ if isinstance(param_values, list):
292
+ updated_values = []
293
+ for i, (value, gradient) in enumerate(zip(param_values, param_gradients)):
294
+ # Apply gradient descent update
295
+ new_value = value - learning_rate * gradient
296
+
297
+ # Enforce constraint: preserve zenith angle (index 1) for mirror alignment
298
+ if (
299
+ param_name
300
+ in ["mirror_align_random_horizontal", "mirror_align_random_vertical"]
301
+ and i == 1
302
+ ):
303
+ new_value = value # Keep original zenith angle value
304
+
305
+ updated_values.append(new_value)
306
+
307
+ new_params[param_name] = updated_values
308
+ else:
309
+ new_value = param_values - learning_rate * param_gradients
310
+ new_params[param_name] = new_value
311
+
312
+ return new_params
313
+
314
+ def _calculate_param_gradient(
315
+ self, current_params, current_metric, param_name, param_values, epsilon
316
+ ):
317
+ """
318
+ Calculate numerical gradient for a single parameter using finite differences.
319
+
320
+ Parameters
321
+ ----------
322
+ current_params : dict
323
+ Dictionary of current parameter values for all optimization parameters.
324
+ current_metric : float
325
+ Current RMSD or KS statistic value.
326
+ param_name : str
327
+ Name of the parameter for which to calculate the gradient.
328
+ param_values : float or list
329
+ Current value(s) of the parameter.
330
+ epsilon : float
331
+ Small perturbation value for finite difference calculation.
332
+
333
+ Returns
334
+ -------
335
+ float or list or None
336
+ Gradient value(s) for the parameter.
337
+ Returns None if simulation fails for any component.
338
+ """
339
+ param_gradients = []
340
+ values_list = param_values if isinstance(param_values, list) else [param_values]
341
+
342
+ for i, value in enumerate(values_list):
343
+ perturbed_params = _create_perturbed_params(
344
+ current_params, param_name, param_values, i, value, epsilon
345
+ )
346
+
347
+ # Calculate gradient for this parameter component using cached simulations
348
+ try:
349
+ _, perturbed_metric, _, _ = self.run_simulation(perturbed_params, use_cache=True)
350
+ gradient = (perturbed_metric - current_metric) / epsilon
351
+ except (ValueError, RuntimeError) as e:
352
+ logger.warning(f"Simulation failed for {param_name}[{i}] gradient calculation: {e}")
353
+ return None
354
+
355
+ param_gradients.append(gradient)
356
+
357
+ return param_gradients[0] if not isinstance(param_values, list) else param_gradients
358
+
359
+ def perform_gradient_step_with_retries(
360
+ self, current_params, current_metric, learning_rate, max_retries=3
361
+ ):
362
+ """
363
+ Attempt gradient descent step with adaptive learning rate reduction.
364
+
365
+ Parameters
366
+ ----------
367
+ current_params : dict
368
+ Dictionary of current parameter values.
369
+ current_metric : float
370
+ Current optimization metric value.
371
+ learning_rate : float
372
+ Initial learning rate for the gradient descent step.
373
+ max_retries : int, optional
374
+ Maximum number of attempts with learning rate reduction.
375
+
376
+ Returns
377
+ -------
378
+ GradientStepResult
379
+ Result object containing updated parameters and step status.
380
+ """
381
+ current_lr = learning_rate
382
+
383
+ for attempt in range(max_retries):
384
+ try:
385
+ gradients = self.calculate_gradient(current_params, current_metric)
386
+
387
+ if gradients is None:
388
+ logger.warning(
389
+ f"Gradient calculation failed on attempt {attempt + 1}, skipping step"
390
+ )
391
+ return GradientStepResult(
392
+ params=None,
393
+ psf_diameter=None,
394
+ metric=None,
395
+ p_value=None,
396
+ simulated_data=None,
397
+ step_accepted=False,
398
+ learning_rate=current_lr,
399
+ )
400
+
401
+ new_params = self.apply_gradient_step(current_params, gradients, current_lr)
402
+
403
+ # Validate that all parameters are within allowed ranges
404
+ if not _are_all_parameters_within_allowed_range(new_params):
405
+ logger.info(
406
+ f"Step rejected: parameters would go out of bounds with learning rate "
407
+ f"{current_lr:.6f}, reducing to {current_lr * self.LR_REDUCTION_FACTOR:.6f}"
408
+ )
409
+ current_lr = self._reduce_learning_rate(current_lr)
410
+ continue
411
+
412
+ new_psf_diameter, new_metric, new_p_value, new_simulated_data = self.run_simulation(
413
+ new_params, use_cache=True
414
+ )
415
+
416
+ if new_metric < current_metric:
417
+ return GradientStepResult(
418
+ params=new_params,
419
+ psf_diameter=new_psf_diameter,
420
+ metric=new_metric,
421
+ p_value=new_p_value,
422
+ simulated_data=new_simulated_data,
423
+ step_accepted=True,
424
+ learning_rate=current_lr,
425
+ )
426
+
427
+ logger.info(
428
+ f"Step rejected (RMSD {current_metric:.6f} -> {new_metric:.6f}), "
429
+ f"reducing learning rate to {current_lr * self.LR_REDUCTION_FACTOR:.6f}"
430
+ )
431
+ current_lr = self._reduce_learning_rate(current_lr)
432
+
433
+ except (ValueError, RuntimeError, KeyError) as e:
434
+ logger.warning(f"Simulation failed on attempt {attempt + 1}: {e}")
435
+ continue
436
+
437
+ return GradientStepResult(
438
+ params=None,
439
+ psf_diameter=None,
440
+ metric=None,
441
+ p_value=None,
442
+ simulated_data=None,
443
+ step_accepted=False,
444
+ learning_rate=current_lr,
445
+ )
446
+
447
+ def _create_step_plot(
448
+ self,
449
+ pdf_pages,
450
+ current_params,
451
+ new_psf_diameter,
452
+ new_metric,
453
+ new_p_value,
454
+ new_simulated_data,
455
+ ):
456
+ """Create plot for an accepted gradient step."""
457
+ if (
458
+ pdf_pages is None
459
+ or not self.args_dict.get("plot_all", False)
460
+ or new_simulated_data is None
461
+ ):
462
+ return
463
+
464
+ self.data_to_plot["simulated"] = new_simulated_data
465
+ plot_psf.create_psf_parameter_plot(
466
+ self.data_to_plot,
467
+ current_params,
468
+ new_psf_diameter,
469
+ new_metric,
470
+ False,
471
+ pdf_pages,
472
+ fraction=self.fraction,
473
+ p_value=new_p_value,
474
+ use_ks_statistic=self.use_ks_statistic,
475
+ )
476
+ del self.data_to_plot["simulated"]
477
+
478
+ def _create_final_plot(self, pdf_pages, best_params, best_psf_diameter):
479
+ """Create final plot for best parameters."""
480
+ if pdf_pages is None or best_params is None:
481
+ return
482
+
483
+ logger.info("Creating final plot for best parameters with both RMSD and KS statistic...")
484
+ _, best_ks_stat, best_p_value, best_simulated_data = self.run_simulation(
485
+ best_params,
486
+ pdf_pages=None,
487
+ is_best=False,
488
+ use_cache=False,
489
+ use_ks_statistic=True,
490
+ )
491
+ best_rmsd = calculate_rmsd(
492
+ self.data_to_plot["measured"][CUMULATIVE_PSF], best_simulated_data[CUMULATIVE_PSF]
493
+ )
494
+
495
+ self.data_to_plot["simulated"] = best_simulated_data
496
+ plot_psf.create_psf_parameter_plot(
497
+ self.data_to_plot,
498
+ best_params,
499
+ best_psf_diameter,
500
+ best_rmsd,
501
+ True,
502
+ pdf_pages,
503
+ fraction=self.fraction,
504
+ p_value=best_p_value,
505
+ use_ks_statistic=False,
506
+ second_metric=best_ks_stat,
507
+ )
508
+ del self.data_to_plot["simulated"]
509
+ pdf_pages.close()
510
+ logger.info("Cumulative PSF plots saved")
511
+
512
+ def run_gradient_descent(self, rmsd_threshold, learning_rate, max_iterations=200):
513
+ """
514
+ Run gradient descent optimization to minimize PSF fitting metric.
515
+
516
+ Parameters
517
+ ----------
518
+ rmsd_threshold : float
519
+ Convergence threshold for RMSD improvement.
520
+ learning_rate : float
521
+ Initial learning rate for gradient descent steps.
522
+ max_iterations : int, optional
523
+ Maximum number of optimization iterations.
524
+
525
+ Returns
526
+ -------
527
+ tuple
528
+ (best_params, best_psf_diameter, results)
529
+ """
530
+ if self.data_to_plot is None or self.radius is None:
531
+ logger.error("No PSF measurement data provided. Cannot run optimization.")
532
+ return None, None, []
533
+
534
+ current_params = self.get_initial_parameters()
535
+ pdf_pages = plot_psf.setup_pdf_plotting(
536
+ self.args_dict, self.output_dir, self.tel_model.name
537
+ )
538
+ results = []
539
+
540
+ # Evaluate initial parameters
541
+ current_psf_diameter, current_metric, current_p_value, simulated_data = self.run_simulation(
542
+ current_params,
543
+ pdf_pages=pdf_pages if self.args_dict.get("plot_all", False) else None,
544
+ is_best=False,
545
+ )
546
+
547
+ results.append(
548
+ (
549
+ current_params.copy(),
550
+ current_metric,
551
+ current_p_value,
552
+ current_psf_diameter,
553
+ simulated_data,
554
+ )
555
+ )
556
+ best_metric, best_params, best_psf_diameter = (
557
+ current_metric,
558
+ current_params.copy(),
559
+ current_psf_diameter,
560
+ )
561
+
562
+ logger.info(
563
+ f"Initial RMSD: {current_metric:.6f}, PSF diameter: {current_psf_diameter:.6f} cm"
564
+ )
565
+
566
+ iteration = 0
567
+ current_lr = learning_rate
568
+
569
+ while iteration < max_iterations:
570
+ if current_metric <= rmsd_threshold:
571
+ logger.info(
572
+ f"Optimization converged: RMSD {current_metric:.6f} <= "
573
+ f"threshold {rmsd_threshold:.6f}"
574
+ )
575
+ break
576
+
577
+ iteration += 1
578
+ logger.info(f"Gradient descent iteration {iteration}")
579
+
580
+ step_result = self.perform_gradient_step_with_retries(
581
+ current_params,
582
+ current_metric,
583
+ current_lr,
584
+ )
585
+
586
+ if not step_result.step_accepted or step_result.params is None:
587
+ current_lr = self._increase_learning_rate(current_lr)
588
+ logger.info(f"No step accepted, increasing learning rate to {current_lr:.6f}")
589
+ continue
590
+
591
+ # Step was accepted - update state
592
+ current_params = step_result.params
593
+ current_metric = step_result.metric
594
+ current_psf_diameter = step_result.psf_diameter
595
+ current_lr = step_result.learning_rate
596
+
597
+ results.append(
598
+ (
599
+ current_params.copy(),
600
+ current_metric,
601
+ None,
602
+ current_psf_diameter,
603
+ step_result.simulated_data,
604
+ )
605
+ )
606
+
607
+ if current_metric < best_metric:
608
+ best_metric, best_params, best_psf_diameter = (
609
+ current_metric,
610
+ current_params.copy(),
611
+ current_psf_diameter,
612
+ )
613
+
614
+ self._create_step_plot(
615
+ pdf_pages,
616
+ current_params,
617
+ step_result.psf_diameter,
618
+ step_result.metric,
619
+ step_result.p_value,
620
+ step_result.simulated_data,
621
+ )
622
+ logger.info(f" Accepted step: improved to {step_result.metric:.6f}")
623
+
624
+ self._create_final_plot(pdf_pages, best_params, best_psf_diameter)
625
+ return best_params, best_psf_diameter, results
626
+
627
+ def analyze_monte_carlo_error(self, n_simulations=500):
628
+ """
629
+ Analyze Monte Carlo uncertainty in PSF optimization metrics.
630
+
631
+ Parameters
632
+ ----------
633
+ n_simulations : int, optional
634
+ Number of Monte Carlo simulations to run.
635
+
636
+ Returns
637
+ -------
638
+ tuple
639
+ Monte Carlo analysis results.
640
+ """
641
+ if self.data_to_plot is None or self.radius is None:
642
+ logger.error("No PSF measurement data provided. Cannot analyze Monte Carlo error.")
643
+ return None, None, [], None, None, [], None, None, []
644
+
645
+ initial_params = self.get_initial_parameters()
646
+ for param_name, param_values in initial_params.items():
647
+ logger.info(f" {param_name}: {param_values}")
648
+
649
+ metric_values, p_values, psf_diameter_values = [], [], []
650
+
651
+ for i in range(n_simulations):
652
+ try:
653
+ psf_diameter, metric, p_value, _ = self.run_simulation(
654
+ initial_params,
655
+ use_cache=False,
656
+ )
657
+ metric_values.append(metric)
658
+ psf_diameter_values.append(psf_diameter)
659
+ p_values.append(p_value)
660
+ except (ValueError, RuntimeError) as e:
661
+ logger.warning(f"WARNING: Simulation {i + 1} failed: {e}")
662
+
663
+ if not metric_values:
664
+ logger.error("All Monte Carlo simulations failed.")
665
+ return None, None, [], None, None, [], None, None, []
666
+
667
+ mean_metric, std_metric = np.mean(metric_values), np.std(metric_values, ddof=1)
668
+ mean_psf_diameter, std_psf_diameter = (
669
+ np.mean(psf_diameter_values),
670
+ np.std(psf_diameter_values, ddof=1),
671
+ )
672
+
673
+ if self.use_ks_statistic:
674
+ valid_p_values = [p for p in p_values if p is not None]
675
+ mean_p_value = np.mean(valid_p_values) if valid_p_values else None
676
+ std_p_value = np.std(valid_p_values, ddof=1) if valid_p_values else None
677
+ else:
678
+ mean_p_value = std_p_value = None
679
+
680
+ return (
681
+ mean_metric,
682
+ std_metric,
683
+ metric_values,
684
+ mean_p_value,
685
+ std_p_value,
686
+ p_values,
687
+ mean_psf_diameter,
688
+ std_psf_diameter,
689
+ psf_diameter_values,
690
+ )
691
+
692
+
693
+ def _is_parameter_within_allowed_range(param_name, param_index, value):
694
+ """
695
+ Check if a parameter value is within the allowed range defined in its schema.
696
+
697
+ Parameters
698
+ ----------
699
+ param_name : str
700
+ Name of the parameter to check.
701
+ param_index : int
702
+ Index within the parameter array (for multi-component parameters).
703
+ value : float
704
+ The parameter value to check.
705
+
706
+ Returns
707
+ -------
708
+ bool
709
+ True if the parameter is within allowed range, False otherwise.
710
+ Returns True if no range constraints are defined for the parameter.
711
+ """
712
+ try:
713
+ param_schema = names.model_parameters().get(param_name)
714
+ if param_schema is None:
715
+ return True
716
+
717
+ data = param_schema.get("data")
718
+ if not isinstance(data, list) or len(data) <= param_index:
719
+ return True
720
+
721
+ param_data = data[param_index]
722
+ allowed_range = param_data.get("allowed_range")
723
+ if not allowed_range:
724
+ return True
725
+
726
+ min_val = allowed_range.get("min")
727
+ max_val = allowed_range.get("max")
728
+
729
+ if min_val is not None and value < min_val:
730
+ return False
731
+ return not (max_val is not None and value > max_val)
732
+
733
+ except (KeyError, IndexError) as e:
734
+ logger.warning(f"Error reading schema for {param_name}[{param_index}]: {e}")
735
+ return True
736
+
737
+
738
+ def _are_all_parameters_within_allowed_range(params):
739
+ """
740
+ Check if all parameters in the parameter dictionary are within allowed ranges.
741
+
742
+ Parameters
743
+ ----------
744
+ params : dict
745
+ Dictionary of parameter values to validate.
746
+
747
+ Returns
748
+ -------
749
+ bool
750
+ True if all parameters are within allowed ranges, False otherwise.
751
+ """
752
+ for name, values in params.items():
753
+ values = values if isinstance(values, list) else [values]
754
+ for i, v in enumerate(values):
755
+ if not _is_parameter_within_allowed_range(name, i, v):
756
+ logger.debug(f"{name}[{i}]={v:.6f} out of range")
757
+ return False
758
+ return True
759
+
760
+
761
+ def _create_perturbed_params(current_params, param_name, param_values, param_index, value, epsilon):
762
+ """
763
+ Create parameter dictionary with one parameter perturbed by epsilon.
764
+
765
+ Parameters
766
+ ----------
767
+ current_params : dict
768
+ Dictionary of current parameter values.
769
+ param_name : str
770
+ Name of the parameter to perturb.
771
+ param_values : float or list
772
+ Current parameter values.
773
+ param_index : int
774
+ Index of the parameter to perturb (for list parameters).
775
+ value : float
776
+ Current value of the specific parameter component.
777
+ epsilon : float
778
+ Perturbation amount.
779
+
780
+ Returns
781
+ -------
782
+ dict
783
+ New parameter dictionary with the specified parameter perturbed.
784
+ """
785
+ perturbed_params = {
786
+ k: v.copy() if isinstance(v, list) else v for k, v in current_params.items()
787
+ }
788
+
789
+ if isinstance(param_values, list):
790
+ perturbed_params[param_name][param_index] = value + epsilon
791
+ else:
792
+ perturbed_params[param_name] = value + epsilon
793
+
794
+ return perturbed_params
795
+
796
+
35
797
  def _create_log_header_and_format_value(title, tel_model, additional_info=None, value=None):
36
798
  """Create log header and format parameter values."""
37
799
  if value is not None: # Format value mode
@@ -137,7 +899,7 @@ def run_psf_simulation(
137
899
  Dictionary of parameter values to test in the simulation.
138
900
  data_to_plot : dict
139
901
  Dictionary containing measured PSF data under "measured" key.
140
- radius : array-like
902
+ radius : numpy.ndarray
141
903
  Radius values in cm for PSF evaluation.
142
904
  pdf_pages : PdfPages, optional
143
905
  PDF pages object for saving plots (default: None).
@@ -340,7 +1102,7 @@ def export_psf_parameters(best_pars, telescope, parameter_version, output_dir):
340
1102
  """
341
1103
  try:
342
1104
  psf_pars_with_units = _add_units_to_psf_parameters(best_pars)
343
- parameter_output_path = output_dir.parent / telescope
1105
+ parameter_output_path = output_dir.joinpath(telescope)
344
1106
  for parameter_name, parameter_value in psf_pars_with_units.items():
345
1107
  writer.ModelDataWriter.dump_model_parameter(
346
1108
  parameter_name=parameter_name,
@@ -356,534 +1118,6 @@ def export_psf_parameters(best_pars, telescope, parameter_version, output_dir):
356
1118
  logger.error(f"Error exporting simulation parameters: {e}")
357
1119
 
358
1120
 
359
- def _calculate_param_gradient(
360
- tel_model,
361
- site_model,
362
- args_dict,
363
- current_params,
364
- data_to_plot,
365
- radius,
366
- current_rmsd,
367
- param_name,
368
- param_values,
369
- epsilon,
370
- use_ks_statistic,
371
- ):
372
- """
373
- Calculate numerical gradient for a single parameter using finite differences.
374
-
375
- The gradient is calculated using forward finite differences:
376
- gradient = (f(x + epsilon) - f(x)) / epsilon
377
-
378
- Parameters
379
- ----------
380
- tel_model : TelescopeModel
381
- The telescope model object containing the current parameter configuration.
382
- site_model : SiteModel
383
- The site model object with environmental conditions.
384
- args_dict : dict
385
- Dictionary containing simulation arguments and configuration options.
386
- current_params : dict
387
- Dictionary of current parameter values for all optimization parameters.
388
- data_to_plot : dict
389
- Dictionary containing measured PSF data with "measured" key.
390
- radius : array-like
391
- Radius values in cm for PSF evaluation.
392
- current_rmsd : float
393
- Current RMSD at the current parameter configuration.
394
- param_name : str
395
- Name of the parameter for which to calculate the gradient.
396
- param_values : float or list
397
- Current value(s) of the parameter. Can be a single value or list of values.
398
- epsilon : float
399
- Small perturbation value for finite difference calculation.
400
- use_ks_statistic : bool
401
- If True, calculate gradient with respect to KS statistic; if False, use RMSD.
402
-
403
- Returns
404
- -------
405
- float or list
406
- Gradient value(s) for the parameter. Returns a single float if param_values
407
- is a single value, or a list of gradients if param_values is a list.
408
-
409
- If a simulation fails during gradient calculation, a gradient of 0.0 is assigned
410
- for that component to ensure the optimization can continue.
411
- """
412
- param_gradients = []
413
- values_list = param_values if isinstance(param_values, list) else [param_values]
414
-
415
- for i, value in enumerate(values_list):
416
- perturbed_params = {
417
- k: v.copy() if isinstance(v, list) else v for k, v in current_params.items()
418
- }
419
-
420
- if isinstance(param_values, list):
421
- perturbed_params[param_name][i] = value + epsilon
422
- else:
423
- perturbed_params[param_name] = value + epsilon
424
-
425
- try:
426
- _, perturbed_rmsd, _, _ = run_psf_simulation(
427
- tel_model,
428
- site_model,
429
- args_dict,
430
- perturbed_params,
431
- data_to_plot,
432
- radius,
433
- pdf_pages=None,
434
- is_best=False,
435
- use_ks_statistic=use_ks_statistic,
436
- )
437
- param_gradients.append((perturbed_rmsd - current_rmsd) / epsilon)
438
- except (ValueError, RuntimeError):
439
- param_gradients.append(0.0)
440
-
441
- return param_gradients[0] if not isinstance(param_values, list) else param_gradients
442
-
443
-
444
- def calculate_gradient(
445
- tel_model,
446
- site_model,
447
- args_dict,
448
- current_params,
449
- data_to_plot,
450
- radius,
451
- current_rmsd,
452
- epsilon=0.0005,
453
- use_ks_statistic=False,
454
- ):
455
- """
456
- Calculate numerical gradients for all optimization parameters.
457
-
458
- Parameters
459
- ----------
460
- tel_model : TelescopeModel
461
- Telescope model object for simulations.
462
- site_model : SiteModel
463
- Site model object with environmental conditions.
464
- args_dict : dict
465
- Dictionary containing simulation configuration arguments.
466
- current_params : dict
467
- Dictionary of current parameter values for all optimization parameters.
468
- data_to_plot : dict
469
- Dictionary containing measured PSF data.
470
- radius : array-like
471
- Radius values in cm for PSF evaluation.
472
- current_rmsd : float
473
- Current RMSD or KS statistic value.
474
- epsilon : float, optional
475
- Perturbation value for finite difference calculation (default: 0.0005).
476
- use_ks_statistic : bool, optional
477
- If True, calculate gradients for KS statistic; if False, use RMSD (default: False).
478
-
479
- Returns
480
- -------
481
- dict
482
- Dictionary mapping parameter names to their gradient values.
483
- For parameters with multiple components, gradients are returned as lists.
484
- """
485
- gradients = {}
486
-
487
- for param_name, param_values in current_params.items():
488
- gradients[param_name] = _calculate_param_gradient(
489
- tel_model,
490
- site_model,
491
- args_dict,
492
- current_params,
493
- data_to_plot,
494
- radius,
495
- current_rmsd,
496
- param_name,
497
- param_values,
498
- epsilon,
499
- use_ks_statistic,
500
- )
501
-
502
- return gradients
503
-
504
-
505
- def apply_gradient_step(current_params, gradients, learning_rate):
506
- """
507
- Apply gradient descent step to update parameters.
508
-
509
- Parameters
510
- ----------
511
- current_params : dict
512
- Dictionary of current parameter values.
513
- gradients : dict
514
- Dictionary of gradient values for each parameter.
515
- learning_rate : float
516
- Step size for the gradient descent update.
517
-
518
- Returns
519
- -------
520
- dict
521
- Dictionary of updated parameter values after applying the gradient step.
522
- """
523
- new_params = {}
524
- for param_name, param_values in current_params.items():
525
- param_gradients = gradients[param_name]
526
-
527
- if isinstance(param_values, list):
528
- new_params[param_name] = [
529
- value - learning_rate * gradient
530
- for value, gradient in zip(param_values, param_gradients)
531
- ]
532
- else:
533
- new_params[param_name] = param_values - learning_rate * param_gradients
534
-
535
- return new_params
536
-
537
-
538
- def _perform_gradient_step_with_retries(
539
- tel_model,
540
- site_model,
541
- args_dict,
542
- current_params,
543
- current_metric,
544
- data_to_plot,
545
- radius,
546
- learning_rate,
547
- max_retries=3,
548
- ):
549
- """
550
- Attempt gradient descent step with adaptive learning rate reduction on rejection.
551
-
552
- The learning rate reduction strategy follows these rules:
553
- - If step is rejected: learning_rate *= 0.7
554
- - If attempt number < number of max retries then try again
555
- - If learning_rate drops below 1e-5: reset to 0.001
556
- - If all retries fail: returns None values with step_accepted=False
557
-
558
- This adaptive approach helps navigate local minima and ensures robust convergence
559
- by automatically adjusting the step size based on optimization progress.
560
-
561
- Parameters
562
- ----------
563
- tel_model : TelescopeModel
564
- Telescope model object containing the current parameter configuration.
565
- site_model : SiteModel
566
- Site model object with environmental conditions for ray tracing simulations.
567
- args_dict : dict
568
- Dictionary containing simulation configuration arguments and settings.
569
- current_params : dict
570
- Dictionary of current parameter values for all optimization parameters.
571
- current_metric : float
572
- Current optimization metric value (RMSD or KS statistic) to improve upon.
573
- data_to_plot : dict
574
- Dictionary containing measured PSF data under "measured" key for comparison.
575
- radius : array-like
576
- Radius values in cm for PSF evaluation and comparison.
577
- learning_rate : float
578
- Initial learning rate for the gradient descent step.
579
- max_retries : int, optional
580
- Maximum number of attempts with learning rate reduction (default: 3).
581
-
582
- Returns
583
- -------
584
- tuple of (dict, float, float, float or None, array, bool, float)
585
- - new_params: Updated parameter dictionary if step accepted, None if rejected
586
- - new_psf_diameter: PSF containment diameter in cm for new parameters, None if step rejected
587
- - new_metric: New optimization metric value, None if step rejected
588
- - new_p_value: p-value from KS test if applicable, None otherwise
589
- - new_simulated_data: Simulated PSF data array, None if step rejected
590
- - step_accepted: Boolean indicating if any step was accepted
591
- - final_learning_rate: Learning rate after potential reductions
592
-
593
- """
594
- current_lr = learning_rate
595
-
596
- for attempt in range(max_retries):
597
- try:
598
- gradients = calculate_gradient(
599
- tel_model,
600
- site_model,
601
- args_dict,
602
- current_params,
603
- data_to_plot,
604
- radius,
605
- current_metric,
606
- use_ks_statistic=False,
607
- )
608
- new_params = apply_gradient_step(current_params, gradients, current_lr)
609
-
610
- new_psf_diameter, new_metric, new_p_value, new_simulated_data = run_psf_simulation(
611
- tel_model,
612
- site_model,
613
- args_dict,
614
- new_params,
615
- data_to_plot,
616
- radius,
617
- pdf_pages=None,
618
- is_best=False,
619
- use_ks_statistic=False,
620
- )
621
-
622
- if new_metric < current_metric:
623
- return (
624
- new_params,
625
- new_psf_diameter,
626
- new_metric,
627
- new_p_value,
628
- new_simulated_data,
629
- True,
630
- current_lr,
631
- )
632
-
633
- logger.info(
634
- f"Step rejected (RMSD {current_metric:.6f} -> {new_metric:.6f}), "
635
- f"reducing learning rate {current_lr:.6f} -> {current_lr * 0.7:.6f}"
636
- )
637
- current_lr *= 0.7
638
-
639
- if current_lr < 1e-5:
640
- current_lr = 0.001
641
-
642
- except (ValueError, RuntimeError, KeyError) as e:
643
- logger.warning(f"Simulation failed on attempt {attempt + 1}: {e}")
644
- continue
645
-
646
- return None, None, None, None, None, False, current_lr
647
-
648
-
649
- def _create_step_plot(
650
- pdf_pages,
651
- args_dict,
652
- data_to_plot,
653
- current_params,
654
- new_psf_diameter,
655
- new_metric,
656
- new_p_value,
657
- new_simulated_data,
658
- ):
659
- """Create plot for an accepted gradient step."""
660
- if pdf_pages is None or not args_dict.get("plot_all", False) or new_simulated_data is None:
661
- return
662
-
663
- data_to_plot["simulated"] = new_simulated_data
664
- plot_psf.create_psf_parameter_plot(
665
- data_to_plot,
666
- current_params,
667
- new_psf_diameter,
668
- new_metric,
669
- False,
670
- pdf_pages,
671
- fraction=args_dict.get("fraction", DEFAULT_FRACTION),
672
- p_value=new_p_value,
673
- use_ks_statistic=False,
674
- )
675
- del data_to_plot["simulated"]
676
-
677
-
678
- def _create_final_plot(
679
- pdf_pages,
680
- tel_model,
681
- site_model,
682
- args_dict,
683
- best_params,
684
- data_to_plot,
685
- radius,
686
- best_psf_diameter,
687
- ):
688
- """Create final plot for best parameters."""
689
- if pdf_pages is None or best_params is None:
690
- return
691
-
692
- logger.info("Creating final plot for best parameters with both RMSD and KS statistic...")
693
- _, best_ks_stat, best_p_value, best_simulated_data = run_psf_simulation(
694
- tel_model,
695
- site_model,
696
- args_dict,
697
- best_params,
698
- data_to_plot,
699
- radius,
700
- pdf_pages=None,
701
- is_best=False,
702
- use_ks_statistic=True,
703
- )
704
- best_rmsd = calculate_rmsd(
705
- data_to_plot["measured"][CUMULATIVE_PSF], best_simulated_data[CUMULATIVE_PSF]
706
- )
707
-
708
- data_to_plot["simulated"] = best_simulated_data
709
- plot_psf.create_psf_parameter_plot(
710
- data_to_plot,
711
- best_params,
712
- best_psf_diameter,
713
- best_rmsd,
714
- True,
715
- pdf_pages,
716
- fraction=args_dict.get("fraction", DEFAULT_FRACTION),
717
- p_value=best_p_value,
718
- use_ks_statistic=False,
719
- second_metric=best_ks_stat,
720
- )
721
- del data_to_plot["simulated"]
722
- pdf_pages.close()
723
- logger.info("Cumulative PSF plots saved")
724
-
725
-
726
- def run_gradient_descent_optimization(
727
- tel_model,
728
- site_model,
729
- args_dict,
730
- data_to_plot,
731
- radius,
732
- rmsd_threshold,
733
- learning_rate,
734
- output_dir,
735
- ):
736
- """
737
- Run gradient descent optimization to minimize PSF fitting metric.
738
-
739
- Parameters
740
- ----------
741
- tel_model : TelescopeModel
742
- Telescope model object to be optimized.
743
- site_model : SiteModel
744
- Site model object with environmental conditions.
745
- args_dict : dict
746
- Dictionary containing simulation configuration arguments.
747
- data_to_plot : dict
748
- Dictionary containing measured PSF data under "measured" key.
749
- radius : array-like
750
- Radius values in cm for PSF evaluation.
751
- rmsd_threshold : float
752
- Convergence threshold for RMSD improvement.
753
- learning_rate : float
754
- Initial learning rate for gradient descent steps.
755
- output_dir : Path
756
- Directory for saving optimization plots and results.
757
-
758
- Returns
759
- -------
760
- tuple of (dict, float, list)
761
- - best_params: Dictionary of optimized parameter values
762
- - best_psf_diameter: PSF containment diameter in cm for the best parameters
763
- - results: List of (params, metric, p_value, psf_diameter, simulated_data)
764
- for each iteration
765
-
766
- Returns None values if optimization fails or no measurement data is provided.
767
- """
768
- if data_to_plot is None or radius is None:
769
- logger.error("No PSF measurement data provided. Cannot run optimization.")
770
- return None, None, []
771
-
772
- current_params = get_previous_values(tel_model)
773
- pdf_pages = plot_psf.setup_pdf_plotting(args_dict, output_dir, tel_model.name)
774
- results = []
775
-
776
- # Evaluate initial parameters
777
- current_psf_diameter, current_metric, current_p_value, simulated_data = run_psf_simulation(
778
- tel_model,
779
- site_model,
780
- args_dict,
781
- current_params,
782
- data_to_plot,
783
- radius,
784
- pdf_pages=pdf_pages if args_dict.get("plot_all", False) else None,
785
- is_best=False,
786
- use_ks_statistic=False,
787
- )
788
-
789
- results.append(
790
- (
791
- current_params.copy(),
792
- current_metric,
793
- current_p_value,
794
- current_psf_diameter,
795
- simulated_data,
796
- )
797
- )
798
- best_metric, best_params, best_psf_diameter = (
799
- current_metric,
800
- current_params.copy(),
801
- current_psf_diameter,
802
- )
803
-
804
- logger.info(f"Initial RMSD: {current_metric:.6f}, PSF diameter: {current_psf_diameter:.6f} cm")
805
-
806
- iteration = 0
807
- max_total_iterations = 100
808
-
809
- while iteration < max_total_iterations:
810
- if current_metric <= rmsd_threshold:
811
- logger.info(
812
- f"Optimization converged: RMSD {current_metric:.6f} <= "
813
- f"threshold {rmsd_threshold:.6f}"
814
- )
815
- break
816
-
817
- iteration += 1
818
- logger.info(f"Gradient descent iteration {iteration}")
819
-
820
- step_result = _perform_gradient_step_with_retries(
821
- tel_model,
822
- site_model,
823
- args_dict,
824
- current_params,
825
- current_metric,
826
- data_to_plot,
827
- radius,
828
- learning_rate,
829
- )
830
- (
831
- new_params,
832
- new_psf_diameter,
833
- new_metric,
834
- new_p_value,
835
- new_simulated_data,
836
- step_accepted,
837
- learning_rate,
838
- ) = step_result
839
-
840
- if not step_accepted or new_params is None:
841
- learning_rate *= 2.0
842
- logger.info(f"No step accepted, increasing learning rate to {learning_rate:.6f}")
843
- continue
844
-
845
- # Step was accepted - update state
846
- current_params, current_metric, current_psf_diameter = (
847
- new_params,
848
- new_metric,
849
- new_psf_diameter,
850
- )
851
- results.append(
852
- (current_params.copy(), current_metric, None, current_psf_diameter, new_simulated_data)
853
- )
854
-
855
- if current_metric < best_metric:
856
- best_metric, best_params, best_psf_diameter = (
857
- current_metric,
858
- current_params.copy(),
859
- current_psf_diameter,
860
- )
861
-
862
- _create_step_plot(
863
- pdf_pages,
864
- args_dict,
865
- data_to_plot,
866
- current_params,
867
- new_psf_diameter,
868
- new_metric,
869
- new_p_value,
870
- new_simulated_data,
871
- )
872
- logger.info(f" Accepted step: improved to {new_metric:.6f}")
873
-
874
- _create_final_plot(
875
- pdf_pages,
876
- tel_model,
877
- site_model,
878
- args_dict,
879
- best_params,
880
- data_to_plot,
881
- radius,
882
- best_psf_diameter,
883
- )
884
- return best_params, best_psf_diameter, results
885
-
886
-
887
1121
  def _write_log_interpretation(f, use_ks_statistic):
888
1122
  """Write interpretation section for the log file."""
889
1123
  if use_ks_statistic:
@@ -1027,103 +1261,6 @@ def write_gradient_descent_log(
1027
1261
  return param_file
1028
1262
 
1029
1263
 
1030
- def analyze_monte_carlo_error(
1031
- tel_model, site_model, args_dict, data_to_plot, radius, n_simulations=500
1032
- ):
1033
- """
1034
- Analyze Monte Carlo uncertainty in PSF optimization metrics.
1035
-
1036
- Runs multiple simulations with the same parameters to quantify the
1037
- statistical uncertainty in the optimization metric due to Monte Carlo
1038
- noise in the ray tracing simulations. Returns None values if no
1039
- measurement data is provided or all simulations fail.
1040
-
1041
- Parameters
1042
- ----------
1043
- tel_model : TelescopeModel
1044
- Telescope model object with current parameter configuration.
1045
- site_model : SiteModel
1046
- Site model object with environmental conditions.
1047
- args_dict : dict
1048
- Dictionary containing simulation configuration arguments.
1049
- data_to_plot : dict
1050
- Dictionary containing measured PSF data under "measured" key.
1051
- radius : array-like
1052
- Radius values in cm for PSF evaluation.
1053
- n_simulations : int, optional
1054
- Number of Monte Carlo simulations to run (default: 500).
1055
-
1056
- Returns
1057
- -------
1058
- tuple of (float, float, list, float, float, list, float, float, list)
1059
- - mean_metric: Mean RMSD or KS statistic value
1060
- - std_metric: Standard deviation of metric values
1061
- - metric_values: List of all metric values from simulations
1062
- - mean_p_value: Mean p-value (None if using RMSD)
1063
- - std_p_value: Standard deviation of p-values (None if using RMSD)
1064
- - p_values: List of all p-values from simulations
1065
- - mean_psf_diameter: Mean PSF containment diameter in cm
1066
- - std_psf_diameter: Standard deviation of PSF diameter values
1067
- - psf_diameter_values: List of all PSF diameter values from simulations
1068
- """
1069
- if data_to_plot is None or radius is None:
1070
- logger.error("No PSF measurement data provided. Cannot analyze Monte Carlo error.")
1071
- return None, None, []
1072
-
1073
- initial_params = get_previous_values(tel_model)
1074
- for param_name, param_values in initial_params.items():
1075
- logger.info(f" {param_name}: {param_values}")
1076
-
1077
- use_ks_statistic = args_dict.get("ks_statistic", False)
1078
- metric_values, p_values, psf_diameter_values = [], [], []
1079
-
1080
- for i in range(n_simulations):
1081
- try:
1082
- psf_diameter, metric, p_value, _ = run_psf_simulation(
1083
- tel_model,
1084
- site_model,
1085
- args_dict,
1086
- initial_params,
1087
- data_to_plot,
1088
- radius,
1089
- use_ks_statistic=use_ks_statistic,
1090
- )
1091
- metric_values.append(metric)
1092
- psf_diameter_values.append(psf_diameter)
1093
- p_values.append(p_value)
1094
- except (ValueError, RuntimeError) as e:
1095
- logger.warning(f"WARNING: Simulation {i + 1} failed: {e}")
1096
-
1097
- if not metric_values:
1098
- logger.error("All Monte Carlo simulations failed.")
1099
- return None, None, [], None, None, []
1100
-
1101
- mean_metric, std_metric = np.mean(metric_values), np.std(metric_values, ddof=1)
1102
- mean_psf_diameter, std_psf_diameter = (
1103
- np.mean(psf_diameter_values),
1104
- np.std(psf_diameter_values, ddof=1),
1105
- )
1106
-
1107
- if use_ks_statistic:
1108
- valid_p_values = [p for p in p_values if p is not None]
1109
- mean_p_value = np.mean(valid_p_values) if valid_p_values else None
1110
- std_p_value = np.std(valid_p_values, ddof=1) if valid_p_values else None
1111
- else:
1112
- mean_p_value = std_p_value = None
1113
-
1114
- return (
1115
- mean_metric,
1116
- std_metric,
1117
- metric_values,
1118
- mean_p_value,
1119
- std_p_value,
1120
- p_values,
1121
- mean_psf_diameter,
1122
- std_psf_diameter,
1123
- psf_diameter_values,
1124
- )
1125
-
1126
-
1127
1264
  def write_monte_carlo_analysis(
1128
1265
  mc_results, output_dir, tel_model, use_ks_statistic=False, fraction=DEFAULT_FRACTION
1129
1266
  ):
@@ -1222,12 +1359,7 @@ def write_monte_carlo_analysis(
1222
1359
  zip(metric_values, p_values, psf_diameter_values)
1223
1360
  ):
1224
1361
  if use_ks_statistic and p_value is not None:
1225
- if p_value > 0.05:
1226
- significance = "GOOD"
1227
- elif p_value > 0.01:
1228
- significance = "FAIR"
1229
- else:
1230
- significance = "POOR"
1362
+ significance = plot_psf.get_significance_label(p_value)
1231
1363
  f.write(
1232
1364
  f"Simulation {i + 1:2d}: {metric_name}={metric_val:.6f}, "
1233
1365
  f"p_value={p_value:.6f} ({significance}), {psf_label}={psf_diameter:.6f} cm\n"
@@ -1241,63 +1373,71 @@ def write_monte_carlo_analysis(
1241
1373
  return mc_file
1242
1374
 
1243
1375
 
1244
- def _handle_monte_carlo_analysis(
1245
- tel_model, site_model, args_dict, data_to_plot, radius, output_dir, use_ks_statistic
1246
- ):
1247
- """Handle Monte Carlo analysis if requested."""
1248
- if not args_dict.get("monte_carlo_analysis", False):
1249
- return False
1250
-
1251
- mc_results = analyze_monte_carlo_error(tel_model, site_model, args_dict, data_to_plot, radius)
1252
- if mc_results[0] is not None:
1253
- mc_file = write_monte_carlo_analysis(
1254
- mc_results,
1255
- output_dir,
1256
- tel_model,
1257
- use_ks_statistic,
1258
- args_dict.get("fraction", DEFAULT_FRACTION),
1259
- )
1260
- logger.info(f"Monte Carlo analysis results written to {mc_file}")
1261
- mc_plot_file = output_dir.joinpath(f"monte_carlo_uncertainty_{tel_model.name}.pdf")
1262
- plot_psf.create_monte_carlo_uncertainty_plot(
1263
- mc_results, mc_plot_file, args_dict.get("fraction", DEFAULT_FRACTION), use_ks_statistic
1264
- )
1265
- return True
1376
+ def cleanup_intermediate_files(output_dir):
1377
+ """
1378
+ Remove intermediate log and list files from the output directory.
1379
+
1380
+ Parameters
1381
+ ----------
1382
+ output_dir : Path
1383
+ Directory containing output files to clean up.
1384
+ """
1385
+ patterns = ["*.log", "*.lis*"]
1386
+ files_removed = 0
1387
+
1388
+ for pattern in patterns:
1389
+ for file_path in output_dir.glob(pattern):
1390
+ file_path.unlink()
1391
+ files_removed += 1
1392
+ logger.debug(f"Removed: {file_path.name}")
1393
+
1394
+ if files_removed > 0:
1395
+ logger.info(f"Cleanup: removed {files_removed} intermediate files")
1266
1396
 
1267
1397
 
1268
1398
  def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
1269
- """Run the complete PSF parameter optimization workflow using gradient descent."""
1399
+ """
1400
+ Run the complete PSF parameter optimization workflow using gradient descent.
1401
+
1402
+ This function creates a PSFParameterOptimizer instance and orchestrates
1403
+ the optimization process.
1404
+ """
1270
1405
  data_to_plot, radius = load_and_process_data(args_dict)
1271
- use_ks_statistic = args_dict.get("ks_statistic", False)
1272
1406
 
1273
- if _handle_monte_carlo_analysis(
1274
- tel_model, site_model, args_dict, data_to_plot, radius, output_dir, use_ks_statistic
1275
- ):
1407
+ # Create optimizer instance to encapsulate state and methods
1408
+ optimizer = PSFParameterOptimizer(
1409
+ tel_model, site_model, args_dict, data_to_plot, radius, output_dir
1410
+ )
1411
+
1412
+ # Handle Monte Carlo analysis if requested
1413
+ if args_dict.get("monte_carlo_analysis", False):
1414
+ mc_results = optimizer.analyze_monte_carlo_error()
1415
+ if mc_results[0] is not None:
1416
+ mc_file = write_monte_carlo_analysis(
1417
+ mc_results,
1418
+ output_dir,
1419
+ tel_model,
1420
+ optimizer.use_ks_statistic,
1421
+ optimizer.fraction,
1422
+ )
1423
+ logger.info(f"Monte Carlo analysis results written to {mc_file}")
1424
+ mc_plot_file = output_dir.joinpath(f"monte_carlo_uncertainty_{tel_model.name}.pdf")
1425
+ plot_psf.create_monte_carlo_uncertainty_plot(
1426
+ mc_results, mc_plot_file, optimizer.fraction, optimizer.use_ks_statistic
1427
+ )
1276
1428
  return
1277
1429
 
1278
1430
  # Run gradient descent optimization
1279
1431
  threshold = args_dict.get("rmsd_threshold")
1280
1432
  learning_rate = args_dict.get("learning_rate")
1281
1433
 
1282
- best_pars, best_psf_diameter, gd_results = run_gradient_descent_optimization(
1283
- tel_model,
1284
- site_model,
1285
- args_dict,
1286
- data_to_plot,
1287
- radius,
1288
- rmsd_threshold=threshold,
1289
- learning_rate=learning_rate,
1290
- output_dir=output_dir,
1434
+ best_pars, best_psf_diameter, gd_results = optimizer.run_gradient_descent(
1435
+ threshold, learning_rate
1291
1436
  )
1292
1437
 
1293
1438
  # Check if optimization was successful
1294
1439
  if not gd_results or best_pars is None:
1295
- logger.error("Gradient descent optimization failed. No valid results found.")
1296
- if radius is None:
1297
- logger.error(
1298
- "Possible cause: No PSF measurement data provided. "
1299
- "Use --data argument to provide PSF data."
1300
- )
1440
+ logger.error("Gradient descent optimization failed to produce results.")
1301
1441
  return
1302
1442
 
1303
1443
  plot_psf.create_optimization_plots(args_dict, gd_results, tel_model, data_to_plot, output_dir)
@@ -1309,8 +1449,8 @@ def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
1309
1449
  gd_results,
1310
1450
  threshold,
1311
1451
  convergence_plot_file,
1312
- args_dict.get("fraction", DEFAULT_FRACTION),
1313
- use_ks_statistic,
1452
+ optimizer.fraction,
1453
+ optimizer.use_ks_statistic,
1314
1454
  )
1315
1455
 
1316
1456
  param_file = write_gradient_descent_log(
@@ -1319,8 +1459,8 @@ def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
1319
1459
  best_psf_diameter,
1320
1460
  output_dir,
1321
1461
  tel_model,
1322
- use_ks_statistic,
1323
- args_dict.get("fraction", DEFAULT_FRACTION),
1462
+ optimizer.use_ks_statistic,
1463
+ optimizer.fraction,
1324
1464
  )
1325
1465
  logger.info(f"\nGradient descent progression written to {param_file}")
1326
1466
 
@@ -1331,3 +1471,6 @@ def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
1331
1471
  export_psf_parameters(
1332
1472
  best_pars, args_dict.get("telescope"), args_dict.get("parameter_version"), output_dir
1333
1473
  )
1474
+
1475
+ if args_dict.get("cleanup", False):
1476
+ cleanup_intermediate_files(output_dir)