gammasimtools 0.20.0__py3-none-any.whl → 0.22.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 (315) hide show
  1. {gammasimtools-0.20.0.dist-info → gammasimtools-0.22.0.dist-info}/METADATA +2 -3
  2. {gammasimtools-0.20.0.dist-info → gammasimtools-0.22.0.dist-info}/RECORD +313 -296
  3. {gammasimtools-0.20.0.dist-info → gammasimtools-0.22.0.dist-info}/entry_points.txt +3 -2
  4. simtools/_version.py +2 -2
  5. simtools/applications/calculate_incident_angles.py +1 -4
  6. simtools/applications/convert_all_model_parameters_from_simtel.py +1 -2
  7. simtools/applications/convert_model_parameter_from_simtel.py +0 -1
  8. simtools/applications/db_generate_compound_indexes.py +4 -17
  9. simtools/applications/db_upload_model_repository.py +122 -0
  10. simtools/applications/derive_psf_parameters.py +71 -42
  11. simtools/applications/docs_produce_array_element_report.py +1 -1
  12. simtools/applications/docs_produce_calibration_reports.py +1 -1
  13. simtools/applications/docs_produce_model_parameter_reports.py +1 -1
  14. simtools/applications/docs_produce_simulation_configuration_report.py +1 -1
  15. simtools/applications/generate_corsika_histograms.py +8 -185
  16. simtools/applications/maintain_simulation_model_add_production.py +81 -0
  17. simtools/applications/merge_tables.py +1 -1
  18. simtools/applications/plot_array_layout.py +1 -2
  19. simtools/applications/plot_simtel_events.py +2 -228
  20. simtools/applications/print_version.py +8 -7
  21. simtools/applications/production_derive_statistics.py +1 -2
  22. simtools/applications/production_generate_grid.py +1 -1
  23. simtools/applications/simulate_flasher.py +74 -72
  24. simtools/applications/simulate_illuminator.py +52 -186
  25. simtools/applications/{simulate_calibration_events.py → simulate_pedestals.py} +9 -55
  26. simtools/applications/submit_model_parameter_from_external.py +0 -1
  27. simtools/applications/validate_camera_efficiency.py +0 -1
  28. simtools/applications/validate_camera_fov.py +1 -2
  29. simtools/applications/validate_cumulative_psf.py +2 -3
  30. simtools/applications/validate_file_using_schema.py +20 -12
  31. simtools/applications/validate_optics.py +2 -2
  32. simtools/camera/camera_efficiency.py +8 -11
  33. simtools/configuration/commandline_parser.py +1 -7
  34. simtools/configuration/configurator.py +0 -2
  35. simtools/corsika/corsika_config.py +9 -11
  36. simtools/corsika/corsika_histograms.py +82 -1
  37. simtools/data_model/model_data_writer.py +87 -25
  38. simtools/data_model/schema.py +61 -2
  39. simtools/data_model/validate_data.py +1 -1
  40. simtools/db/db_handler.py +103 -48
  41. simtools/db/db_model_upload.py +247 -16
  42. simtools/io/io_handler.py +31 -83
  43. simtools/job_execution/job_manager.py +45 -0
  44. simtools/layout/array_layout_utils.py +1 -5
  45. simtools/model/array_model.py +93 -42
  46. simtools/model/model_parameter.py +20 -9
  47. simtools/model/model_repository.py +197 -109
  48. simtools/model/model_utils.py +21 -6
  49. simtools/model/telescope_model.py +20 -0
  50. simtools/production_configuration/derive_corsika_limits.py +1 -1
  51. simtools/ray_tracing/incident_angles.py +7 -7
  52. simtools/ray_tracing/mirror_panel_psf.py +1 -1
  53. simtools/ray_tracing/psf_parameter_optimisation.py +1106 -565
  54. simtools/ray_tracing/ray_tracing.py +1 -3
  55. simtools/reporting/docs_read_parameters.py +171 -101
  56. simtools/resources/array_elements.yml +26 -0
  57. simtools/runners/corsika_simtel_runner.py +11 -17
  58. simtools/runners/runner_services.py +5 -6
  59. simtools/runners/simtools_runner.py +0 -2
  60. simtools/schemas/application_workflow.metaschema.yml +1 -1
  61. simtools/schemas/common_definitions.schema.yml +39 -0
  62. simtools/schemas/model_parameter.metaschema.yml +19 -13
  63. simtools/schemas/model_parameter_and_data_schema.metaschema.yml +6 -12
  64. simtools/schemas/model_parameters/adjust_gain.schema.yml +0 -5
  65. simtools/schemas/model_parameters/altitude.schema.yml +0 -5
  66. simtools/schemas/model_parameters/array_coordinates.schema.yml +0 -5
  67. simtools/schemas/model_parameters/array_coordinates_UTM.schema.yml +0 -5
  68. simtools/schemas/model_parameters/array_element_position_ground.schema.yml +0 -7
  69. simtools/schemas/model_parameters/array_element_position_utm.schema.yml +0 -7
  70. simtools/schemas/model_parameters/array_layouts.schema.yml +0 -5
  71. simtools/schemas/model_parameters/array_triggers.schema.yml +0 -5
  72. simtools/schemas/model_parameters/array_window.schema.yml +0 -7
  73. simtools/schemas/model_parameters/asum_clipping.schema.yml +0 -3
  74. simtools/schemas/model_parameters/asum_offset.schema.yml +0 -7
  75. simtools/schemas/model_parameters/asum_shaping.schema.yml +0 -7
  76. simtools/schemas/model_parameters/asum_threshold.schema.yml +0 -7
  77. simtools/schemas/model_parameters/atmospheric_profile.schema.yml +0 -5
  78. simtools/schemas/model_parameters/atmospheric_transmission.schema.yml +0 -5
  79. simtools/schemas/model_parameters/axes_offsets.schema.yml +0 -7
  80. simtools/schemas/model_parameters/calibration_devices.schema.yml +30 -0
  81. simtools/schemas/model_parameters/camera_body_diameter.schema.yml +0 -7
  82. simtools/schemas/model_parameters/camera_body_shape.schema.yml +0 -7
  83. simtools/schemas/model_parameters/camera_config_file.schema.yml +0 -7
  84. simtools/schemas/model_parameters/camera_config_rotate.schema.yml +0 -7
  85. simtools/schemas/model_parameters/camera_degraded_efficiency.schema.yml +0 -7
  86. simtools/schemas/model_parameters/camera_degraded_map.schema.yml +0 -7
  87. simtools/schemas/model_parameters/camera_depth.schema.yml +0 -7
  88. simtools/schemas/model_parameters/camera_filter.schema.yml +0 -7
  89. simtools/schemas/model_parameters/camera_filter_incidence_angle.schema.yml +0 -3
  90. simtools/schemas/model_parameters/camera_pixels.schema.yml +0 -7
  91. simtools/schemas/model_parameters/camera_transmission.schema.yml +0 -7
  92. simtools/schemas/model_parameters/channels_per_chip.schema.yml +0 -7
  93. simtools/schemas/model_parameters/correct_nsb_spectrum_to_telescope_altitude.schema.yml +0 -7
  94. simtools/schemas/model_parameters/corsika_observation_level.schema.yml +0 -5
  95. simtools/schemas/model_parameters/dark_events.schema.yml +4 -3
  96. simtools/schemas/model_parameters/default_trigger.schema.yml +0 -7
  97. simtools/schemas/model_parameters/design_model.schema.yml +0 -7
  98. simtools/schemas/model_parameters/disc_ac_coupled.schema.yml +0 -7
  99. simtools/schemas/model_parameters/disc_bins.schema.yml +0 -7
  100. simtools/schemas/model_parameters/disc_start.schema.yml +0 -7
  101. simtools/schemas/model_parameters/discriminator_amplitude.schema.yml +0 -7
  102. simtools/schemas/model_parameters/discriminator_fall_time.schema.yml +0 -7
  103. simtools/schemas/model_parameters/discriminator_gate_length.schema.yml +0 -7
  104. simtools/schemas/model_parameters/discriminator_hysteresis.schema.yml +0 -7
  105. simtools/schemas/model_parameters/discriminator_output_amplitude.schema.yml +0 -7
  106. simtools/schemas/model_parameters/discriminator_output_var_percent.schema.yml +0 -7
  107. simtools/schemas/model_parameters/discriminator_pulse_shape.schema.yml +0 -7
  108. simtools/schemas/model_parameters/discriminator_rise_time.schema.yml +0 -7
  109. simtools/schemas/model_parameters/discriminator_scale_threshold.schema.yml +0 -7
  110. simtools/schemas/model_parameters/discriminator_sigsum_over_threshold.schema.yml +0 -7
  111. simtools/schemas/model_parameters/discriminator_threshold.schema.yml +0 -7
  112. simtools/schemas/model_parameters/discriminator_time_over_threshold.schema.yml +1 -9
  113. simtools/schemas/model_parameters/discriminator_var_gate_length.schema.yml +0 -7
  114. simtools/schemas/model_parameters/discriminator_var_sigsum_over_threshold.schema.yml +0 -7
  115. simtools/schemas/model_parameters/discriminator_var_threshold.schema.yml +0 -7
  116. simtools/schemas/model_parameters/discriminator_var_time_over_threshold.schema.yml +0 -7
  117. simtools/schemas/model_parameters/dish_shape_length.schema.yml +0 -5
  118. simtools/schemas/model_parameters/dsum_clipping.schema.yml +1 -5
  119. simtools/schemas/model_parameters/dsum_ignore_below.schema.yml +0 -3
  120. simtools/schemas/model_parameters/dsum_offset.schema.yml +0 -3
  121. simtools/schemas/model_parameters/dsum_pedsub.schema.yml +0 -3
  122. simtools/schemas/model_parameters/dsum_pre_clipping.schema.yml +0 -3
  123. simtools/schemas/model_parameters/dsum_prescale.schema.yml +0 -3
  124. simtools/schemas/model_parameters/dsum_presum_max.schema.yml +0 -3
  125. simtools/schemas/model_parameters/dsum_presum_shift.schema.yml +0 -3
  126. simtools/schemas/model_parameters/dsum_shaping.schema.yml +0 -3
  127. simtools/schemas/model_parameters/dsum_shaping_renormalize.schema.yml +0 -3
  128. simtools/schemas/model_parameters/dsum_threshold.schema.yml +2 -12
  129. simtools/schemas/model_parameters/dsum_zero_clip.schema.yml +0 -3
  130. simtools/schemas/model_parameters/effective_focal_length.schema.yml +0 -7
  131. simtools/schemas/model_parameters/epsg_code.schema.yml +0 -5
  132. simtools/schemas/model_parameters/fadc_ac_coupled.schema.yml +0 -7
  133. simtools/schemas/model_parameters/fadc_amplitude.schema.yml +2 -9
  134. simtools/schemas/model_parameters/fadc_bins.schema.yml +0 -7
  135. simtools/schemas/model_parameters/fadc_compensate_pedestal.schema.yml +0 -7
  136. simtools/schemas/model_parameters/fadc_dev_pedestal.schema.yml +0 -2
  137. simtools/schemas/model_parameters/fadc_err_compensate_pedestal.schema.yml +0 -7
  138. simtools/schemas/model_parameters/fadc_err_pedestal.schema.yml +0 -7
  139. simtools/schemas/model_parameters/fadc_lg_amplitude.schema.yml +2 -9
  140. simtools/schemas/model_parameters/fadc_lg_compensate_pedestal.schema.yml +0 -7
  141. simtools/schemas/model_parameters/fadc_lg_dev_pedestal.schema.yml +0 -2
  142. simtools/schemas/model_parameters/fadc_lg_err_compensate_pedestal.schema.yml +0 -7
  143. simtools/schemas/model_parameters/fadc_lg_err_pedestal.schema.yml +0 -7
  144. simtools/schemas/model_parameters/fadc_lg_max_signal.schema.yml +0 -7
  145. simtools/schemas/model_parameters/fadc_lg_max_sum.schema.yml +0 -2
  146. simtools/schemas/model_parameters/fadc_lg_noise.schema.yml +0 -7
  147. simtools/schemas/model_parameters/fadc_lg_pedestal.schema.yml +0 -7
  148. simtools/schemas/model_parameters/fadc_lg_sensitivity.schema.yml +0 -7
  149. simtools/schemas/model_parameters/fadc_lg_sysvar_pedestal.schema.yml +0 -7
  150. simtools/schemas/model_parameters/fadc_lg_var_pedestal.schema.yml +0 -7
  151. simtools/schemas/model_parameters/fadc_lg_var_sensitivity.schema.yml +0 -7
  152. simtools/schemas/model_parameters/fadc_long_event_threshold.schema.yml +0 -3
  153. simtools/schemas/model_parameters/fadc_long_sum_bins.schema.yml +0 -3
  154. simtools/schemas/model_parameters/fadc_long_sum_offset.schema.yml +0 -3
  155. simtools/schemas/model_parameters/fadc_max_signal.schema.yml +0 -7
  156. simtools/schemas/model_parameters/fadc_max_sum.schema.yml +0 -2
  157. simtools/schemas/model_parameters/fadc_mhz.schema.yml +0 -7
  158. simtools/schemas/model_parameters/fadc_noise.schema.yml +0 -7
  159. simtools/schemas/model_parameters/fadc_pedestal.schema.yml +0 -7
  160. simtools/schemas/model_parameters/fadc_pulse_shape.schema.yml +0 -7
  161. simtools/schemas/model_parameters/fadc_sensitivity.schema.yml +0 -7
  162. simtools/schemas/model_parameters/fadc_sum_bins.schema.yml +0 -7
  163. simtools/schemas/model_parameters/fadc_sum_offset.schema.yml +0 -7
  164. simtools/schemas/model_parameters/fadc_sysvar_pedestal.schema.yml +0 -7
  165. simtools/schemas/model_parameters/fadc_var_pedestal.schema.yml +0 -7
  166. simtools/schemas/model_parameters/fadc_var_sensitivity.schema.yml +0 -7
  167. simtools/schemas/model_parameters/fake_mirror_list.schema.yml +0 -3
  168. simtools/schemas/model_parameters/flasher_angular_distribution.schema.yml +32 -0
  169. simtools/schemas/model_parameters/flasher_angular_distribution_width.schema.yml +32 -0
  170. simtools/schemas/model_parameters/flasher_bunch_size.schema.yml +28 -0
  171. simtools/schemas/model_parameters/flasher_external_trigger.schema.yml +32 -0
  172. simtools/schemas/model_parameters/flasher_photons.schema.yml +34 -0
  173. simtools/schemas/model_parameters/flasher_position.schema.yml +43 -0
  174. simtools/schemas/model_parameters/flasher_pulse_exp_decay.schema.yml +29 -0
  175. simtools/schemas/model_parameters/flasher_pulse_offset.schema.yml +35 -0
  176. simtools/schemas/model_parameters/flasher_pulse_shape.schema.yml +30 -0
  177. simtools/schemas/model_parameters/flasher_pulse_width.schema.yml +32 -0
  178. simtools/schemas/model_parameters/flasher_type.schema.yml +28 -0
  179. simtools/schemas/model_parameters/flasher_var_photons.schema.yml +31 -0
  180. simtools/schemas/model_parameters/flasher_wavelength.schema.yml +33 -0
  181. simtools/schemas/model_parameters/flatfielding.schema.yml +0 -7
  182. simtools/schemas/model_parameters/focal_length.schema.yml +0 -7
  183. simtools/schemas/model_parameters/focal_surface_parameters.schema.yml +0 -3
  184. simtools/schemas/model_parameters/focal_surface_ref_radius.schema.yml +0 -3
  185. simtools/schemas/model_parameters/focus_offset.schema.yml +0 -7
  186. simtools/schemas/model_parameters/gain_variation.schema.yml +0 -7
  187. simtools/schemas/model_parameters/geomag_horizontal.schema.yml +2 -7
  188. simtools/schemas/model_parameters/geomag_rotation.schema.yml +2 -7
  189. simtools/schemas/model_parameters/geomag_vertical.schema.yml +2 -7
  190. simtools/schemas/model_parameters/hg_lg_variation.schema.yml +0 -5
  191. simtools/schemas/model_parameters/iobuf_maximum.schema.yml +0 -7
  192. simtools/schemas/model_parameters/iobuf_output_maximum.schema.yml +0 -7
  193. simtools/schemas/model_parameters/laser_events.schema.yml +4 -3
  194. simtools/schemas/model_parameters/laser_external_trigger.schema.yml +4 -3
  195. simtools/schemas/model_parameters/laser_photons.schema.yml +4 -3
  196. simtools/schemas/model_parameters/laser_pulse_exptime.schema.yml +4 -3
  197. simtools/schemas/model_parameters/laser_pulse_offset.schema.yml +4 -3
  198. simtools/schemas/model_parameters/laser_pulse_sigtime.schema.yml +4 -3
  199. simtools/schemas/model_parameters/laser_pulse_twidth.schema.yml +4 -3
  200. simtools/schemas/model_parameters/laser_var_photons.schema.yml +4 -3
  201. simtools/schemas/model_parameters/laser_wavelength.schema.yml +4 -3
  202. simtools/schemas/model_parameters/led_events.schema.yml +4 -3
  203. simtools/schemas/model_parameters/led_photons.schema.yml +4 -3
  204. simtools/schemas/model_parameters/led_pulse_offset.schema.yml +4 -3
  205. simtools/schemas/model_parameters/led_pulse_sigtime.schema.yml +4 -3
  206. simtools/schemas/model_parameters/led_var_photons.schema.yml +4 -3
  207. simtools/schemas/model_parameters/lightguide_efficiency_vs_incidence_angle.schema.yml +0 -7
  208. simtools/schemas/model_parameters/lightguide_efficiency_vs_wavelength.schema.yml +0 -7
  209. simtools/schemas/model_parameters/min_photoelectrons.schema.yml +0 -7
  210. simtools/schemas/model_parameters/min_photons.schema.yml +0 -7
  211. simtools/schemas/model_parameters/mirror_align_random_distance.schema.yml +0 -5
  212. simtools/schemas/model_parameters/mirror_align_random_horizontal.schema.yml +0 -7
  213. simtools/schemas/model_parameters/mirror_align_random_vertical.schema.yml +0 -7
  214. simtools/schemas/model_parameters/mirror_class.schema.yml +2 -9
  215. simtools/schemas/model_parameters/mirror_degraded_reflection.schema.yml +0 -7
  216. simtools/schemas/model_parameters/mirror_focal_length.schema.yml +0 -5
  217. simtools/schemas/model_parameters/mirror_list.schema.yml +0 -7
  218. simtools/schemas/model_parameters/mirror_offset.schema.yml +0 -7
  219. simtools/schemas/model_parameters/mirror_reflection_random_angle.schema.yml +0 -7
  220. simtools/schemas/model_parameters/mirror_reflectivity.schema.yml +0 -7
  221. simtools/schemas/model_parameters/multiplicity_offset.schema.yml +0 -7
  222. simtools/schemas/model_parameters/muon_mono_threshold.schema.yml +0 -7
  223. simtools/schemas/model_parameters/nsb_autoscale_airmass.schema.yml +0 -7
  224. simtools/schemas/model_parameters/nsb_gain_drop_scale.schema.yml +0 -3
  225. simtools/schemas/model_parameters/nsb_offaxis.schema.yml +0 -7
  226. simtools/schemas/model_parameters/nsb_pixel_rate.schema.yml +0 -7
  227. simtools/schemas/model_parameters/nsb_reference_spectrum.schema.yml +0 -5
  228. simtools/schemas/model_parameters/nsb_reference_value.schema.yml +0 -5
  229. simtools/schemas/model_parameters/nsb_scaling_factor.schema.yml +0 -5
  230. simtools/schemas/model_parameters/nsb_sky_map.schema.yml +0 -5
  231. simtools/schemas/model_parameters/nsb_spectrum.schema.yml +0 -5
  232. simtools/schemas/model_parameters/num_gains.schema.yml +0 -7
  233. simtools/schemas/model_parameters/only_triggered_telescopes.schema.yml +0 -7
  234. simtools/schemas/model_parameters/optics_properties.schema.yml +0 -7
  235. simtools/schemas/model_parameters/parabolic_dish.schema.yml +0 -3
  236. simtools/schemas/model_parameters/pedestal_events.schema.yml +4 -7
  237. simtools/schemas/model_parameters/photon_delay.schema.yml +0 -7
  238. simtools/schemas/model_parameters/photons_per_run.schema.yml +4 -4
  239. simtools/schemas/model_parameters/pixel_cells.schema.yml +0 -3
  240. simtools/schemas/model_parameters/pixels_parallel.schema.yml +0 -3
  241. simtools/schemas/model_parameters/pixeltrg_time_step.schema.yml +0 -7
  242. simtools/schemas/model_parameters/pm_average_gain.schema.yml +0 -5
  243. simtools/schemas/model_parameters/pm_collection_efficiency.schema.yml +0 -5
  244. simtools/schemas/model_parameters/pm_gain_index.schema.yml +0 -5
  245. simtools/schemas/model_parameters/pm_photoelectron_spectrum.schema.yml +0 -7
  246. simtools/schemas/model_parameters/pm_transit_time.schema.yml +4 -9
  247. simtools/schemas/model_parameters/pm_voltage_variation.schema.yml +0 -5
  248. simtools/schemas/model_parameters/primary_mirror_degraded_map.schema.yml +0 -7
  249. simtools/schemas/model_parameters/primary_mirror_diameter.schema.yml +0 -3
  250. simtools/schemas/model_parameters/primary_mirror_hole_diameter.schema.yml +0 -3
  251. simtools/schemas/model_parameters/primary_mirror_incidence_angle.schema.yml +0 -3
  252. simtools/schemas/model_parameters/primary_mirror_parameters.schema.yml +0 -3
  253. simtools/schemas/model_parameters/primary_mirror_ref_radius.schema.yml +0 -3
  254. simtools/schemas/model_parameters/primary_mirror_segmentation.schema.yml +0 -3
  255. simtools/schemas/model_parameters/qe_variation.schema.yml +0 -7
  256. simtools/schemas/model_parameters/quantum_efficiency.schema.yml +0 -7
  257. simtools/schemas/model_parameters/random_focal_length.schema.yml +2 -7
  258. simtools/schemas/model_parameters/random_generator.schema.yml +0 -7
  259. simtools/schemas/model_parameters/random_mono_probability.schema.yml +0 -7
  260. simtools/schemas/model_parameters/reference_point_altitude.schema.yml +0 -5
  261. simtools/schemas/model_parameters/reference_point_latitude.schema.yml +0 -5
  262. simtools/schemas/model_parameters/reference_point_longitude.schema.yml +0 -5
  263. simtools/schemas/model_parameters/reference_point_utm_east.schema.yml +0 -5
  264. simtools/schemas/model_parameters/reference_point_utm_north.schema.yml +0 -5
  265. simtools/schemas/model_parameters/sampled_output.schema.yml +0 -7
  266. simtools/schemas/model_parameters/save_pe_with_amplitude.schema.yml +0 -7
  267. simtools/schemas/model_parameters/secondary_mirror_baffle.schema.yml +0 -3
  268. simtools/schemas/model_parameters/secondary_mirror_degraded_map.schema.yml +0 -3
  269. simtools/schemas/model_parameters/secondary_mirror_degraded_reflection.schema.yml +0 -3
  270. simtools/schemas/model_parameters/secondary_mirror_diameter.schema.yml +0 -3
  271. simtools/schemas/model_parameters/secondary_mirror_hole_diameter.schema.yml +0 -3
  272. simtools/schemas/model_parameters/secondary_mirror_incidence_angle.schema.yml +0 -3
  273. simtools/schemas/model_parameters/secondary_mirror_parameters.schema.yml +0 -3
  274. simtools/schemas/model_parameters/secondary_mirror_ref_radius.schema.yml +0 -3
  275. simtools/schemas/model_parameters/secondary_mirror_reflectivity.schema.yml +0 -3
  276. simtools/schemas/model_parameters/secondary_mirror_segmentation.schema.yml +0 -3
  277. simtools/schemas/model_parameters/secondary_mirror_shadow_diameter.schema.yml +0 -3
  278. simtools/schemas/model_parameters/secondary_mirror_shadow_offset.schema.yml +0 -3
  279. simtools/schemas/model_parameters/stars.schema.yml +0 -5
  280. simtools/schemas/model_parameters/store_photoelectrons.schema.yml +0 -7
  281. simtools/schemas/model_parameters/tailcut_scale.schema.yml +0 -7
  282. simtools/schemas/model_parameters/telescope_axis_height.schema.yml +0 -7
  283. simtools/schemas/model_parameters/telescope_random_angle.schema.yml +0 -7
  284. simtools/schemas/model_parameters/telescope_random_error.schema.yml +0 -7
  285. simtools/schemas/model_parameters/telescope_sphere_radius.schema.yml +0 -7
  286. simtools/schemas/model_parameters/telescope_transmission.schema.yml +0 -7
  287. simtools/schemas/model_parameters/teltrig_min_sigsum.schema.yml +0 -7
  288. simtools/schemas/model_parameters/teltrig_min_time.schema.yml +0 -7
  289. simtools/schemas/model_parameters/transit_time_calib_error.schema.yml +0 -7
  290. simtools/schemas/model_parameters/transit_time_compensate_error.schema.yml +0 -7
  291. simtools/schemas/model_parameters/transit_time_compensate_step.schema.yml +0 -7
  292. simtools/schemas/model_parameters/transit_time_error.schema.yml +0 -7
  293. simtools/schemas/model_parameters/transit_time_jitter.schema.yml +0 -7
  294. simtools/schemas/model_parameters/trigger_current_limit.schema.yml +0 -7
  295. simtools/schemas/model_parameters/trigger_delay_compensation.schema.yml +0 -7
  296. simtools/schemas/model_parameters/trigger_pixels.schema.yml +0 -7
  297. simtools/schemas/production_tables.schema.yml +8 -8
  298. simtools/schemas/simulation_models_info.schema.yml +78 -0
  299. simtools/simtel/simtel_config_writer.py +88 -14
  300. simtools/simtel/simulator_array.py +44 -74
  301. simtools/simtel/simulator_light_emission.py +336 -629
  302. simtools/simtel/simulator_ray_tracing.py +2 -2
  303. simtools/simulator.py +46 -18
  304. simtools/testing/configuration.py +4 -2
  305. simtools/testing/sim_telarray_metadata.py +4 -4
  306. simtools/utils/geometry.py +34 -0
  307. simtools/version.py +111 -0
  308. simtools/{corsika/corsika_histograms_visualize.py → visualization/plot_corsika_histograms.py} +109 -0
  309. simtools/visualization/plot_psf.py +775 -0
  310. simtools/visualization/plot_simtel_events.py +284 -87
  311. simtools/applications/maintain_simulation_model_add_production_table.py +0 -71
  312. simtools/model/flasher_model.py +0 -106
  313. {gammasimtools-0.20.0.dist-info → gammasimtools-0.22.0.dist-info}/WHEEL +0 -0
  314. {gammasimtools-0.20.0.dist-info → gammasimtools-0.22.0.dist-info}/licenses/LICENSE +0 -0
  315. {gammasimtools-0.20.0.dist-info → gammasimtools-0.22.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,89 @@ 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
24
+ from simtools.visualization.plot_psf import DEFAULT_FRACTION, get_psf_diameter_label
26
25
 
27
26
  logger = logging.getLogger(__name__)
28
27
 
28
+
29
29
  # Constants
30
- RADIUS_CM = "Radius [cm]"
30
+ RADIUS = "Radius"
31
31
  CUMULATIVE_PSF = "Cumulative PSF"
32
+ KS_STATISTIC_NAME = "KS statistic"
32
33
 
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
39
34
 
35
+ def _create_log_header_and_format_value(title, tel_model, additional_info=None, value=None):
36
+ """Create log header and format parameter values."""
37
+ if value is not None: # Format value mode
38
+ if isinstance(value, list):
39
+ return "[" + ", ".join([f"{v:.6f}" for v in value]) + "]"
40
+ if isinstance(value, int | float):
41
+ return f"{value:.6f}"
42
+ return str(value)
40
43
 
41
- def load_psf_data(data_file):
42
- """
43
- Load data from a text file containing cumulative PSF measurements.
44
-
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
44
+ # Create header mode
45
+ header_lines = [f"# {title}", f"# Telescope: {tel_model.name}"]
46
+ if additional_info:
47
+ for key, val in additional_info.items():
48
+ header_lines.append(f"# {key}: {val}")
49
+ header_lines.extend(["#" + "=" * 65, ""])
50
+ return "\n".join(header_lines) + "\n"
63
51
 
64
52
 
65
53
  def calculate_rmsd(data, sim):
66
- """Calculate Root Mean Squared Deviation to be used as metric to find the best parameters."""
54
+ """Calculate RMSD between measured and simulated cumulative PSF curves."""
67
55
  return np.sqrt(np.mean((data - sim) ** 2))
68
56
 
69
57
 
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)
58
+ def calculate_ks_statistic(data, sim):
59
+ """Calculate the KS statistic between measured and simulated cumulative PSF curves."""
60
+ return stats.ks_2samp(data, sim)
106
61
 
107
62
 
108
63
  def get_previous_values(tel_model):
109
64
  """
110
- Retrieve previous parameter values from the telescope model.
65
+ Retrieve current PSF parameter values from the telescope model.
111
66
 
112
67
  Parameters
113
68
  ----------
114
69
  tel_model : TelescopeModel
115
- Telescope model object.
70
+ Telescope model object containing parameter configurations.
116
71
 
117
72
  Returns
118
73
  -------
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.
74
+ dict
75
+ Dictionary containing current values of PSF optimization parameters:
76
+ - 'mirror_reflection_random_angle': Random reflection angle parameters
77
+ - 'mirror_align_random_horizontal': Horizontal alignment parameters
78
+ - 'mirror_align_random_vertical': Vertical alignment parameters
163
79
  """
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)
80
+ return {
81
+ "mirror_reflection_random_angle": tel_model.get_parameter_value(
82
+ "mirror_reflection_random_angle"
83
+ ),
84
+ "mirror_align_random_horizontal": tel_model.get_parameter_value(
85
+ "mirror_align_random_horizontal"
86
+ ),
87
+ "mirror_align_random_vertical": tel_model.get_parameter_value(
88
+ "mirror_align_random_vertical"
89
+ ),
90
+ }
190
91
 
191
92
 
192
93
  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:
94
+ """Run a ray tracing simulation with the given telescope parameters."""
95
+ if pars is None:
215
96
  raise ValueError("No best parameters found")
216
97
 
98
+ tel_model.change_multiple_parameters(**pars)
217
99
  ray = RayTracing(
218
100
  telescope_model=tel_model,
219
101
  site_model=site_model,
@@ -225,568 +107,1227 @@ def _run_ray_tracing_simulation(tel_model, site_model, args_dict, pars):
225
107
  ray.simulate(test=args_dict.get("test", False), force=True)
226
108
  ray.analyze(force=True, use_rx=False)
227
109
  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()
110
+ fraction = args_dict.get("fraction", DEFAULT_FRACTION)
111
+ return im.get_psf(fraction=fraction), im
303
112
 
304
113
 
305
114
  def run_psf_simulation(
306
115
  tel_model,
307
- site_model,
116
+ site,
308
117
  args_dict,
309
118
  pars,
310
119
  data_to_plot,
311
120
  radius,
312
121
  pdf_pages=None,
313
122
  is_best=False,
314
- return_simulated_data=False,
123
+ use_ks_statistic=False,
315
124
  ):
316
125
  """
317
- Run the simulation for one set of parameters and return D80, RMSD.
126
+ Run PSF simulation for given parameters and calculate optimization metric.
318
127
 
319
128
  Parameters
320
129
  ----------
321
130
  tel_model : TelescopeModel
322
- Telescope model object.
323
- site_model : SiteModel
324
- Site model object.
131
+ Telescope model object to be configured with the test parameters.
132
+ site : Site
133
+ Site model object with environmental conditions.
325
134
  args_dict : dict
326
- Dictionary containing parsed command-line arguments.
135
+ Dictionary containing simulation configuration arguments.
327
136
  pars : dict
328
- Parameter set dictionary.
137
+ Dictionary of parameter values to test in the simulation.
329
138
  data_to_plot : dict
330
- Data dictionary for plotting.
139
+ Dictionary containing measured PSF data under "measured" key.
331
140
  radius : array-like
332
- Radius data.
141
+ Radius values in cm for PSF evaluation.
333
142
  pdf_pages : PdfPages, optional
334
- PDF pages object for plotting. If None, no plotting is done.
143
+ PDF pages object for saving plots (default: None).
335
144
  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.
145
+ Flag indicating if this is the best parameter set (default: False).
146
+ use_ks_statistic : bool, optional
147
+ If True, use KS statistic as metric; if False, use RMSD (default: False).
339
148
 
340
149
  Returns
341
150
  -------
342
- tuple
343
- (d80, rmsd) if return_simulated_data=False
344
- (d80, rmsd, simulated_data) if return_simulated_data=True
151
+ tuple of (float, float, float or None, array)
152
+ - psf_diameter: PSF containment diameter of the simulated PSF in cm
153
+ - metric: RMSD or KS statistic value
154
+ - p_value: p-value from KS test (None if using RMSD)
155
+ - simulated_data: Structured array with simulated cumulative PSF data
345
156
  """
346
- d80, im = _run_ray_tracing_simulation(tel_model, site_model, args_dict, pars)
157
+ psf_diameter, im = _run_ray_tracing_simulation(tel_model, site, args_dict, pars)
347
158
 
348
159
  if radius is None:
349
160
  raise ValueError("Radius data is not available.")
350
161
 
351
162
  simulated_data = im.get_cumulative_data(radius * u.cm)
352
- rmsd = calculate_rmsd(data_to_plot["measured"][CUMULATIVE_PSF], simulated_data[CUMULATIVE_PSF])
163
+
164
+ if use_ks_statistic:
165
+ ks_statistic, p_value = calculate_ks_statistic(
166
+ data_to_plot["measured"][CUMULATIVE_PSF], simulated_data[CUMULATIVE_PSF]
167
+ )
168
+ metric = ks_statistic
169
+ else:
170
+ metric = calculate_rmsd(
171
+ data_to_plot["measured"][CUMULATIVE_PSF], simulated_data[CUMULATIVE_PSF]
172
+ )
173
+ p_value = None
353
174
 
354
175
  # Handle plotting if requested
355
176
  if pdf_pages is not None and args_dict.get("plot_all", False):
356
177
  data_to_plot["simulated"] = simulated_data
357
- _create_psf_simulation_plot(data_to_plot, pars, d80, rmsd, is_best, pdf_pages)
178
+ plot_psf.create_psf_parameter_plot(
179
+ data_to_plot,
180
+ pars,
181
+ psf_diameter,
182
+ metric,
183
+ is_best,
184
+ pdf_pages,
185
+ fraction=args_dict.get("fraction", DEFAULT_FRACTION),
186
+ p_value=p_value,
187
+ use_ks_statistic=use_ks_statistic,
188
+ )
358
189
  del data_to_plot["simulated"]
359
190
 
360
- return (d80, rmsd, simulated_data) if return_simulated_data else (d80, rmsd)
191
+ return psf_diameter, metric, p_value, simulated_data
361
192
 
362
193
 
363
194
  def load_and_process_data(args_dict):
364
195
  """
365
- Load and process data if specified in the command-line arguments.
196
+ Load and process PSF measurement data from ECSV file.
197
+
198
+ Parameters
199
+ ----------
200
+ args_dict : dict
201
+ Dictionary containing command-line arguments with 'data' and 'model_path' keys.
366
202
 
367
203
  Returns
368
204
  -------
369
- - data_to_plot: OrderedDict containing loaded and processed data.
370
- - radius: Radius data from loaded data (if available).
205
+ tuple of (OrderedDict, array)
206
+ - data_dict: OrderedDict with "measured" key containing structured array
207
+ of radius and cumulative PSF data
208
+ - radius: Array of radius values in cm
209
+
210
+ Raises
211
+ ------
212
+ FileNotFoundError
213
+ If no data file is specified in args_dict.
371
214
  """
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
215
+ if args_dict["data"] is None:
216
+ raise FileNotFoundError("No data file specified for PSF optimization.")
217
+
218
+ data_file = gen.find_file(args_dict["data"], args_dict["model_path"])
219
+ table = Table.read(data_file, format="ascii.ecsv")
220
+
221
+ radius_column = next((col for col in table.colnames if "radius" in col.lower()), None)
222
+ integral_psf_column = next((col for col in table.colnames if "integral" in col.lower()), None)
223
+
224
+ # Create structured array with converted data
225
+ d_type = {"names": (RADIUS, CUMULATIVE_PSF), "formats": ("f8", "f8")}
226
+ data = np.zeros(len(table), dtype=d_type)
379
227
 
228
+ data[RADIUS] = table[radius_column].to(u.cm).value
229
+ data[CUMULATIVE_PSF] = table[integral_psf_column]
230
+ data[CUMULATIVE_PSF] /= np.max(np.abs(data[CUMULATIVE_PSF])) # Normalize to max = 1.0
231
+
232
+ return OrderedDict([("measured", data)]), data[RADIUS]
380
233
 
381
- def _create_plot_for_parameters(pars, rmsd, d80, simulated_data, data_to_plot, is_best, pdf_pages):
234
+
235
+ def write_tested_parameters_to_file(
236
+ results, best_pars, best_psf_diameter, output_dir, tel_model, fraction=DEFAULT_FRACTION
237
+ ):
382
238
  """
383
- Create a single plot for a parameter set.
239
+ Write optimization results and tested parameters to a log file.
384
240
 
385
241
  Parameters
386
242
  ----------
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
243
+ results : list
244
+ List of tuples containing (parameters, ks_statistic, p_value, psf_diameter, simulated_data)
245
+ for each tested parameter set.
246
+ best_pars : dict
247
+ Dictionary containing the best parameter values found.
248
+ best_psf_diameter : float
249
+ PSF containment diameter in cm for the best parameter set.
250
+ output_dir : Path
251
+ Directory where the log file will be written.
252
+ tel_model : TelescopeModel
253
+ Telescope model object for naming the output file.
254
+ fraction : float, optional
255
+ PSF containment fraction for labeling (default: 0.8).
256
+
257
+ Returns
258
+ -------
259
+ Path
260
+ Path to the created log file.
401
261
  """
402
- original_simulated = data_to_plot.get("simulated")
403
- data_to_plot["simulated"] = simulated_data
262
+ param_file = output_dir.joinpath(f"psf_optimization_{tel_model.name}.log")
263
+ psf_label = get_psf_diameter_label(fraction)
404
264
 
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
- )
265
+ with open(param_file, "w", encoding="utf-8") as f:
266
+ header = _create_log_header_and_format_value(
267
+ "PSF Parameter Optimization Log",
268
+ tel_model,
269
+ {"Total parameter sets tested": len(results)},
270
+ )
271
+ f.write(header)
430
272
 
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
- )
273
+ f.write("PARAMETER TESTING RESULTS:\n")
274
+ for i, (pars, ks_statistic, p_value, psf_diameter, _) in enumerate(results):
275
+ status = "BEST" if pars is best_pars else "TESTED"
276
+ f.write(
277
+ f"[{status}] Set {i + 1:03d}: KS_stat={ks_statistic:.5f}, "
278
+ f"p_value={p_value:.5f}, {psf_label}={psf_diameter:.5f} cm\n"
279
+ )
280
+ for par, value in pars.items():
281
+ f.write(f" {par}: {value}\n")
282
+ f.write("\n")
447
283
 
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
- )
284
+ f.write("OPTIMIZATION SUMMARY:\n")
285
+ f.write(f"Best KS statistic: {min(result[1] for result in results):.5f}\n")
286
+ f.write(f"Best {psf_label}: {best_psf_diameter:.5f} cm\n")
287
+ f.write("\nOPTIMIZED PARAMETERS:\n")
288
+ for par, value in best_pars.items():
289
+ f.write(f"{par}: {value}\n")
290
+ return param_file
457
291
 
458
- pdf_pages.savefig(fig, bbox_inches="tight")
459
- plt.clf()
460
292
 
461
- if original_simulated is not None:
462
- data_to_plot["simulated"] = original_simulated
293
+ def _add_units_to_psf_parameters(best_pars):
294
+ """Add astropy units to PSF parameters based on their schemas."""
295
+ psf_pars_with_units = {}
296
+ for param_name, param_values in best_pars.items():
297
+ if param_name == "mirror_reflection_random_angle":
298
+ psf_pars_with_units[param_name] = [
299
+ param_values[0] * u.deg,
300
+ param_values[1] * u.dimensionless_unscaled,
301
+ param_values[2] * u.deg,
302
+ ]
303
+ elif param_name in ["mirror_align_random_horizontal", "mirror_align_random_vertical"]:
304
+ psf_pars_with_units[param_name] = [
305
+ param_values[0] * u.deg,
306
+ param_values[1] * u.deg,
307
+ param_values[2] * u.dimensionless_unscaled,
308
+ param_values[3] * u.dimensionless_unscaled,
309
+ ]
310
+ else:
311
+ psf_pars_with_units[param_name] = param_values
312
+ return psf_pars_with_units
463
313
 
464
314
 
465
- def _create_all_plots(results, best_pars, data_to_plot, pdf_pages):
315
+ def export_psf_parameters(best_pars, telescope, parameter_version, output_dir):
466
316
  """
467
- Create plots for all parameter sets if requested.
317
+ Export optimized PSF parameters as simulation model parameter files.
468
318
 
469
319
  Parameters
470
320
  ----------
471
- results : list
472
- List of (pars, rmsd, d80, simulated_data) tuples
473
321
  best_pars : dict
474
- Best parameter set for highlighting
322
+ Dictionary containing the optimized parameter values.
323
+ telescope : str
324
+ Telescope name for the parameter files.
325
+ parameter_version : str
326
+ Version string for the parameter files.
327
+ output_dir : Path
328
+ Base directory for parameter file output.
329
+
330
+ Notes
331
+ -----
332
+ Creates individual JSON files for each optimized parameter with
333
+ units. Files are saved in the format:
334
+ {output_dir}/{telescope}/{parameter_name}-{parameter_version}.json
335
+
336
+ Raises
337
+ ------
338
+ ValueError, KeyError, OSError
339
+ If parameter export fails due to invalid values, missing keys, or file I/O errors.
340
+ """
341
+ try:
342
+ psf_pars_with_units = _add_units_to_psf_parameters(best_pars)
343
+ parameter_output_path = output_dir.parent / telescope
344
+ for parameter_name, parameter_value in psf_pars_with_units.items():
345
+ writer.ModelDataWriter.dump_model_parameter(
346
+ parameter_name=parameter_name,
347
+ value=parameter_value,
348
+ instrument=telescope,
349
+ parameter_version=parameter_version,
350
+ output_file=f"{parameter_name}-{parameter_version}.json",
351
+ output_path=parameter_output_path,
352
+ )
353
+ logger.info(f"simulation model parameter files exported to {output_dir}")
354
+
355
+ except (ValueError, KeyError, OSError) as e:
356
+ logger.error(f"Error exporting simulation parameters: {e}")
357
+
358
+
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.
475
388
  data_to_plot : dict
476
- Data dictionary for plotting
477
- pdf_pages : PdfPages
478
- PDF pages object to save plots
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.
479
411
  """
480
- logger.info("Creating plots for all parameter sets...")
412
+ param_gradients = []
413
+ values_list = param_values if isinstance(param_values, list) else [param_values]
481
414
 
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 ''}")
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
+ }
485
419
 
486
- _create_plot_for_parameters(
487
- pars, rmsd, d80, simulated_data, data_to_plot, is_best, pdf_pages
488
- )
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
489
442
 
490
443
 
491
- def find_best_parameters(
492
- all_parameters, tel_model, site_model, args_dict, data_to_plot, radius, pdf_pages=None
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,
493
454
  ):
494
455
  """
495
- Find the best parameters by running simulations for all parameter sets.
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.
496
517
 
497
- Loop over all parameter sets, run the simulation, compute RMSD,
498
- and return the best parameters and their RMSD.
518
+ Returns
519
+ -------
520
+ dict
521
+ Dictionary of updated parameter values after applying the gradient step.
499
522
  """
500
- best_rmsd = float("inf")
501
- best_pars = None
502
- best_d80 = None
503
- results = [] # Store (pars, rmsd, d80, simulated_data)
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
504
534
 
505
- logger.info(f"Running {len(all_parameters)} simulations...")
535
+ return new_params
506
536
 
507
- for i, pars in enumerate(all_parameters):
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):
508
597
  try:
509
- logger.info(f"Running simulation {i + 1}/{len(all_parameters)}")
510
- d80, rmsd, simulated_data = run_psf_simulation(
598
+ gradients = calculate_gradient(
511
599
  tel_model,
512
600
  site_model,
513
601
  args_dict,
514
- pars,
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,
515
615
  data_to_plot,
516
616
  radius,
517
- return_simulated_data=True,
518
617
  pdf_pages=None,
618
+ is_best=False,
619
+ use_ks_statistic=False,
519
620
  )
520
- except (ValueError, RuntimeError) as e:
521
- logger.warning(f"Simulation failed for parameters {pars}: {e}")
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}")
522
644
  continue
523
645
 
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
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"]
529
676
 
530
- logger.info(f"Best RMSD found: {best_rmsd:.5f}")
531
677
 
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)
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
+ )
535
707
 
536
- return best_pars, best_d80, results
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")
537
724
 
538
725
 
539
- def create_d80_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, output_dir):
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
+ ):
540
736
  """
541
- Create D80 vs off-axis angle plot using the best parameters.
737
+ Run gradient descent optimization to minimize PSF fitting metric.
542
738
 
543
739
  Parameters
544
740
  ----------
545
741
  tel_model : TelescopeModel
546
- Telescope model object.
742
+ Telescope model object to be optimized.
547
743
  site_model : SiteModel
548
- Site model object.
744
+ Site model object with environmental conditions.
549
745
  args_dict : dict
550
- Dictionary containing parsed command-line arguments.
551
- best_pars : dict
552
- Best parameter set.
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.
553
755
  output_dir : Path
554
- Output directory for saving plots.
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.
555
767
  """
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,
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,
568
787
  )
569
788
 
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,
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,
577
802
  )
578
803
 
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)
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
582
885
 
583
- for key in ["d80_cm", "d80_deg"]:
584
- plt.figure(figsize=(10, 6), tight_layout=True)
585
886
 
586
- ray.plot(key, marker="o", linestyle="-", color="blue", linewidth=2, markersize=6)
887
+ def _write_log_interpretation(f, use_ks_statistic):
888
+ """Write interpretation section for the log file."""
889
+ if use_ks_statistic:
890
+ f.write(
891
+ "P-VALUE INTERPRETATION:\n p > 0.05: Distributions are statistically similar "
892
+ "(good fit)\n"
893
+ " p < 0.05: Distributions are significantly different (poor fit)\n"
894
+ " p < 0.01: Very significant difference (very poor fit)\n\n"
895
+ )
896
+ else:
897
+ f.write(
898
+ "RMSD INTERPRETATION:\n Lower RMSD values indicate better agreement between "
899
+ "measured and simulated PSF curves\n\n"
900
+ )
587
901
 
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"
902
+
903
+ def _write_iteration_entry(
904
+ f,
905
+ iteration,
906
+ pars,
907
+ metric,
908
+ p_value,
909
+ psf_diameter,
910
+ use_ks_statistic,
911
+ metric_name,
912
+ total_iterations,
913
+ fraction=DEFAULT_FRACTION,
914
+ ):
915
+ """Write a single iteration entry."""
916
+ status = "FINAL" if iteration == total_iterations - 1 else f"ITER-{iteration:02d}"
917
+
918
+ if use_ks_statistic and p_value is not None:
919
+ significance = plot_psf.get_significance_label(p_value)
920
+ label = get_psf_diameter_label(fraction)
921
+ f.write(
922
+ f"[{status}] Iteration {iteration}: KS_stat={metric:.6f}, "
923
+ f"p_value={p_value:.6f} ({significance}), {label}={psf_diameter:.6f} cm\n"
924
+ )
925
+ else:
926
+ label = get_psf_diameter_label(fraction)
927
+ f.write(
928
+ f"[{status}] Iteration {iteration}: {metric_name}={metric:.6f}, "
929
+ f"{label}={psf_diameter:.6f} cm\n"
596
930
  )
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
931
 
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})")
932
+ for par, value in pars.items():
933
+ f.write(f" {par}: {_create_log_header_and_format_value(None, None, None, value)}\n")
934
+ f.write("\n")
607
935
 
608
- plt.close("all")
936
+
937
+ def _write_optimization_summary(
938
+ f, gd_results, best_pars, best_psf_diameter, metric_name, fraction=DEFAULT_FRACTION
939
+ ):
940
+ """Write optimization summary section."""
941
+ f.write("OPTIMIZATION SUMMARY:\n")
942
+ best_metric_from_results = min(metric for _, metric, _, _, _ in gd_results)
943
+ f.write(f"Best {metric_name.lower()}: {best_metric_from_results:.6f}\n")
944
+
945
+ label = get_psf_diameter_label(fraction)
946
+ f.write(
947
+ f"Best {label}: {best_psf_diameter:.6f} cm\n"
948
+ if best_psf_diameter is not None
949
+ else f"Best {label}: N/A\n"
950
+ )
951
+ f.write(f"Total iterations: {len(gd_results)}\n\nFINAL OPTIMIZED PARAMETERS:\n")
952
+ for par, value in best_pars.items():
953
+ f.write(f"{par}: {_create_log_header_and_format_value(None, None, None, value)}\n")
609
954
 
610
955
 
611
- def write_tested_parameters_to_file(results, best_pars, best_d80, output_dir, tel_model):
956
+ def write_gradient_descent_log(
957
+ gd_results,
958
+ best_pars,
959
+ best_psf_diameter,
960
+ output_dir,
961
+ tel_model,
962
+ use_ks_statistic=False,
963
+ fraction=DEFAULT_FRACTION,
964
+ ):
612
965
  """
613
- Write all tested parameters and their metrics to a text file.
966
+ Write gradient descent optimization progression to a log file.
614
967
 
615
968
  Parameters
616
969
  ----------
617
- results : list
618
- List of (pars, rmsd, d80, simulated_data) tuples
970
+ gd_results : list
971
+ List of tuples containing (params, metric, p_value, psf_diameter, simulated_data)
972
+ for each optimization iteration.
619
973
  best_pars : dict
620
- Best parameter set
621
- best_d80 : float
622
- Best D80 value
974
+ Dictionary containing the best parameter values found.
975
+ best_psf_diameter : float
976
+ PSF containment diameter in cm for the best parameter set.
623
977
  output_dir : Path
624
- Output directory path
978
+ Directory where the log file will be written.
625
979
  tel_model : TelescopeModel
626
- Telescope model object for filename generation
980
+ Telescope model object for naming the output file.
981
+ use_ks_statistic : bool, optional
982
+ If True, log KS statistic values; if False, log RMSD values (default: False).
983
+ fraction : float, optional
984
+ PSF containment fraction for labeling (default: 0.8).
985
+
986
+ Returns
987
+ -------
988
+ Path
989
+ Path to the created log file.
627
990
  """
628
- param_file = output_dir.joinpath(f"psf_optimization_{tel_model.name}.log")
991
+ metric_name = "KS Statistic" if use_ks_statistic else "RMSD"
992
+ file_suffix = "ks" if use_ks_statistic else "rmsd"
993
+ param_file = output_dir.joinpath(f"psf_gradient_descent_{file_suffix}_{tel_model.name}.log")
994
+
629
995
  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")
996
+ header = _create_log_header_and_format_value(
997
+ f"PSF Parameter Optimization - Gradient Descent Progression ({metric_name})",
998
+ tel_model,
999
+ {"Total iterations": len(gd_results)},
1000
+ )
1001
+ f.write(header)
634
1002
 
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")
1003
+ f.write(
1004
+ "GRADIENT DESCENT PROGRESSION:\n(Each entry shows the parameters chosen "
1005
+ "at each iteration)\n\n"
1006
+ )
1007
+ _write_log_interpretation(f, use_ks_statistic)
1008
+
1009
+ for iteration, (pars, metric, p_value, psf_diameter, _) in enumerate(gd_results):
1010
+ _write_iteration_entry(
1011
+ f,
1012
+ iteration,
1013
+ pars,
1014
+ metric,
1015
+ p_value,
1016
+ psf_diameter,
1017
+ use_ks_statistic,
1018
+ metric_name,
1019
+ len(gd_results),
1020
+ fraction,
1021
+ )
1022
+
1023
+ _write_optimization_summary(
1024
+ f, gd_results, best_pars, best_psf_diameter, metric_name, fraction
1025
+ )
643
1026
 
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
1027
  return param_file
651
1028
 
652
1029
 
653
- def _add_units_to_psf_parameters(best_pars):
1030
+ def analyze_monte_carlo_error(
1031
+ tel_model, site_model, args_dict, data_to_plot, radius, n_simulations=500
1032
+ ):
654
1033
  """
655
- Add proper astropy units to PSF parameters based on their schemas.
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.
656
1040
 
657
1041
  Parameters
658
1042
  ----------
659
- best_pars : dict
660
- Dictionary with PSF parameter names as keys and values as lists
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).
661
1055
 
662
1056
  Returns
663
1057
  -------
664
- dict
665
- Dictionary with same keys but values converted to astropy quantities with units
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
666
1068
  """
667
- psf_pars_with_units = {}
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, []
668
1072
 
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
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}")
685
1076
 
686
- return psf_pars_with_units
1077
+ use_ks_statistic = args_dict.get("ks_statistic", False)
1078
+ metric_values, p_values, psf_diameter_values = [], [], []
687
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}")
688
1096
 
689
- def export_psf_parameters(best_pars, tel_model, parameter_version, output_dir):
690
- """
691
- Export PSF parameters as simulation model parameter files.
1097
+ if not metric_values:
1098
+ logger.error("All Monte Carlo simulations failed.")
1099
+ return None, None, [], None, None, []
692
1100
 
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}")
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
+ )
723
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
+ )
724
1125
 
725
- def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
726
- """
727
- Run the complete PSF parameter optimization workflow.
728
1126
 
729
- This function consolidates the main optimization logic to make the application lighter.
1127
+ def write_monte_carlo_analysis(
1128
+ mc_results, output_dir, tel_model, use_ks_statistic=False, fraction=DEFAULT_FRACTION
1129
+ ):
1130
+ """
1131
+ Write Monte Carlo uncertainty analysis results to a log file.
730
1132
 
731
1133
  Parameters
732
1134
  ----------
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
1135
+ mc_results : tuple
1136
+ Tuple of Monte Carlo analysis results from analyze_monte_carlo_error().
739
1137
  output_dir : Path
740
- Output directory path
1138
+ Directory where the log file will be written.
1139
+ tel_model : TelescopeModel
1140
+ Telescope model object for naming the output file.
1141
+ use_ks_statistic : bool, optional
1142
+ If True, analyze KS statistic results; if False, analyze RMSD results (default: False).
1143
+ fraction : float, optional
1144
+ PSF containment fraction for labeling (default: 0.8).
741
1145
 
742
1146
  Returns
743
1147
  -------
744
- None
745
- All results are saved to files and printed to console
1148
+ Path
1149
+ Path to the created log file.
746
1150
  """
747
- # Generate parameter sets
748
- all_parameters = []
749
- mrra_0, mfr_0, mrra2_0, mar_0 = get_previous_values(tel_model)
1151
+ (
1152
+ mean_metric,
1153
+ std_metric,
1154
+ metric_values,
1155
+ mean_p_value,
1156
+ std_p_value,
1157
+ p_values,
1158
+ mean_psf_diameter,
1159
+ std_psf_diameter,
1160
+ psf_diameter_values,
1161
+ ) = mc_results
1162
+
1163
+ metric_name = "KS Statistic" if use_ks_statistic else "RMSD"
1164
+ file_suffix = "ks" if use_ks_statistic else "rmsd"
1165
+ mc_file = output_dir.joinpath(f"monte_carlo_{file_suffix}_analysis_{tel_model.name}.log")
1166
+
1167
+ psf_label = get_psf_diameter_label(fraction)
1168
+
1169
+ with open(mc_file, "w", encoding="utf-8") as f:
1170
+ header = _create_log_header_and_format_value(
1171
+ f"Monte Carlo {metric_name} Error Analysis",
1172
+ tel_model,
1173
+ {"Number of simulations": len(metric_values)},
1174
+ )
1175
+ f.write(header)
750
1176
 
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
- )
1177
+ f.write(
1178
+ f"MONTE CARLO SIMULATION RESULTS:\nNumber of successful simulations: "
1179
+ f"{len(metric_values)}\n\n"
1180
+ )
1181
+ f.write(f"{metric_name.upper()} STATISTICS:\n")
1182
+ f.write(
1183
+ f"Mean {metric_name.lower()}: {mean_metric:.6f}\n"
1184
+ f"Standard deviation: {std_metric:.6f}\n"
1185
+ f"Minimum {metric_name.lower()}: {min(metric_values):.6f}\n"
1186
+ f"Maximum {metric_name.lower()}: {max(metric_values):.6f}\n"
1187
+ f"Relative error: {(std_metric / mean_metric) * 100:.2f}%\n\n"
1188
+ )
1189
+
1190
+ if use_ks_statistic and mean_p_value is not None:
1191
+ valid_p_values = [p for p in p_values if p is not None]
1192
+ f.write(
1193
+ f"P-VALUE STATISTICS:\nMean p-value: {mean_p_value:.6f}\n"
1194
+ f"Standard deviation: {std_p_value:.6f}\n"
1195
+ f"Minimum p-value: {min(valid_p_values):.6f}\n"
1196
+ f"Maximum p-value: {max(valid_p_values):.6f}\n"
1197
+ f"Relative error: {(std_p_value / mean_p_value) * 100:.2f}%\n"
1198
+ )
755
1199
 
1200
+ good_fits = sum(1 for p in valid_p_values if p > 0.05)
1201
+ fair_fits = sum(1 for p in valid_p_values if 0.01 < p <= 0.05)
1202
+ poor_fits = sum(1 for p in valid_p_values if p <= 0.01)
1203
+ f.write(
1204
+ f"Good fits (p > 0.05): {good_fits}/{len(valid_p_values)} "
1205
+ f"({100 * good_fits / len(valid_p_values):.1f}%)\n"
1206
+ f"Fair fits (0.01 < p <= 0.05): {fair_fits}/{len(valid_p_values)} "
1207
+ f"({100 * fair_fits / len(valid_p_values):.1f}%)\n"
1208
+ f"Poor fits (p <= 0.01): {poor_fits}/{len(valid_p_values)} "
1209
+ f"({100 * poor_fits / len(valid_p_values):.1f}%)\n\n"
1210
+ )
1211
+
1212
+ f.write(
1213
+ f"{psf_label} STATISTICS:\nMean {psf_label}: {mean_psf_diameter:.6f} cm\n"
1214
+ f"Standard deviation: {std_psf_diameter:.6f} cm\n"
1215
+ f"Minimum {psf_label}: {min(psf_diameter_values):.6f} cm\n"
1216
+ f"Maximum {psf_label}: {max(psf_diameter_values):.6f} cm\n"
1217
+ f"Relative error: {(std_psf_diameter / mean_psf_diameter) * 100:.2f}%\n\n"
1218
+ )
1219
+
1220
+ f.write("INDIVIDUAL SIMULATION RESULTS:\n")
1221
+ for i, (metric_val, p_value, psf_diameter) in enumerate(
1222
+ zip(metric_values, p_values, psf_diameter_values)
1223
+ ):
1224
+ 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"
1231
+ f.write(
1232
+ f"Simulation {i + 1:2d}: {metric_name}={metric_val:.6f}, "
1233
+ f"p_value={p_value:.6f} ({significance}), {psf_label}={psf_diameter:.6f} cm\n"
1234
+ )
1235
+ else:
1236
+ f.write(
1237
+ f"Simulation {i + 1:2d}: {metric_name}={metric_val:.6f}, "
1238
+ f"{psf_label}={psf_diameter:.6f} cm\n"
1239
+ )
1240
+
1241
+ return mc_file
1242
+
1243
+
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
1266
+
1267
+
1268
+ def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
1269
+ """Run the complete PSF parameter optimization workflow using gradient descent."""
756
1270
  data_to_plot, radius = load_and_process_data(args_dict)
1271
+ use_ks_statistic = args_dict.get("ks_statistic", False)
757
1272
 
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)
1273
+ if _handle_monte_carlo_analysis(
1274
+ tel_model, site_model, args_dict, data_to_plot, radius, output_dir, use_ks_statistic
1275
+ ):
1276
+ return
762
1277
 
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
1278
+ # Run gradient descent optimization
1279
+ threshold = args_dict.get("rmsd_threshold")
1280
+ learning_rate = args_dict.get("learning_rate")
1281
+
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,
766
1291
  )
767
1292
 
768
- plt.close()
769
- pdf_pages.close()
1293
+ # Check if optimization was successful
1294
+ 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
+ )
1301
+ return
1302
+
1303
+ plot_psf.create_optimization_plots(args_dict, gd_results, tel_model, data_to_plot, output_dir)
770
1304
 
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
1305
+ convergence_plot_file = output_dir.joinpath(
1306
+ f"gradient_descent_convergence_{tel_model.name}.png"
1307
+ )
1308
+ plot_psf.create_gradient_descent_convergence_plot(
1309
+ gd_results,
1310
+ threshold,
1311
+ convergence_plot_file,
1312
+ args_dict.get("fraction", DEFAULT_FRACTION),
1313
+ use_ks_statistic,
774
1314
  )
775
- print(f"\nParameter results written to {param_file}")
776
1315
 
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")
1316
+ param_file = write_gradient_descent_log(
1317
+ gd_results,
1318
+ best_pars,
1319
+ best_psf_diameter,
1320
+ output_dir,
1321
+ tel_model,
1322
+ use_ks_statistic,
1323
+ args_dict.get("fraction", DEFAULT_FRACTION),
1324
+ )
1325
+ logger.info(f"\nGradient descent progression written to {param_file}")
780
1326
 
781
- print("\nBest parameters:")
782
- for par, value in best_pars.items():
783
- print(f"{par} = {value}")
1327
+ plot_psf.create_psf_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, output_dir)
784
1328
 
785
- # Export best parameters as simulation model parameter files (if flag is provided)
786
1329
  if args_dict.get("write_psf_parameters", False):
1330
+ logger.info("Exporting best parameters as model files...")
787
1331
  export_psf_parameters(
788
- best_pars,
789
- tel_model,
790
- args_dict.get("parameter_version", "0.0.0"),
791
- output_dir.parent,
1332
+ best_pars, args_dict.get("telescope"), args_dict.get("parameter_version"), output_dir
792
1333
  )