gammasimtools 0.25.0__py3-none-any.whl → 0.27.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/METADATA +6 -1
  2. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/RECORD +135 -130
  3. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/WHEEL +1 -1
  4. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/entry_points.txt +3 -2
  5. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/licenses/LICENSE +1 -1
  6. simtools/_version.py +2 -2
  7. simtools/application_control.py +35 -7
  8. simtools/applications/convert_geo_coordinates_of_array_elements.py +3 -3
  9. simtools/applications/db_add_file_to_db.py +1 -1
  10. simtools/applications/db_add_simulation_model_from_repository_to_db.py +1 -1
  11. simtools/applications/db_add_value_from_json_to_db.py +1 -1
  12. simtools/applications/db_generate_compound_indexes.py +1 -1
  13. simtools/applications/db_get_array_layouts_from_db.py +3 -7
  14. simtools/applications/db_get_file_from_db.py +1 -1
  15. simtools/applications/db_get_parameter_from_db.py +1 -1
  16. simtools/applications/db_inspect_databases.py +1 -1
  17. simtools/applications/db_upload_model_repository.py +1 -1
  18. simtools/applications/derive_ctao_array_layouts.py +1 -2
  19. simtools/applications/{calculate_incident_angles.py → derive_incident_angle.py} +16 -18
  20. simtools/applications/derive_mirror_rnda.py +112 -180
  21. simtools/applications/derive_psf_parameters.py +0 -1
  22. simtools/applications/derive_pulse_shape_parameters.py +0 -1
  23. simtools/applications/derive_trigger_rates.py +1 -1
  24. simtools/applications/docs_produce_array_element_report.py +2 -8
  25. simtools/applications/docs_produce_calibration_reports.py +1 -3
  26. simtools/applications/docs_produce_model_parameter_reports.py +0 -2
  27. simtools/applications/docs_produce_simulation_configuration_report.py +1 -3
  28. simtools/applications/generate_array_config.py +0 -1
  29. simtools/applications/generate_corsika_histograms.py +79 -229
  30. simtools/applications/generate_regular_arrays.py +76 -69
  31. simtools/applications/generate_simtel_event_data.py +2 -2
  32. simtools/applications/maintain_simulation_model_add_production.py +2 -2
  33. simtools/applications/maintain_simulation_model_write_array_element_positions.py +87 -0
  34. simtools/applications/plot_array_layout.py +5 -111
  35. simtools/applications/plot_simulated_event_distributions.py +57 -0
  36. simtools/applications/plot_tabular_data.py +0 -1
  37. simtools/applications/plot_tabular_data_for_model_parameter.py +1 -6
  38. simtools/applications/production_derive_corsika_limits.py +1 -1
  39. simtools/applications/production_generate_grid.py +0 -1
  40. simtools/applications/run_application.py +1 -1
  41. simtools/applications/simulate_flasher.py +3 -15
  42. simtools/applications/simulate_illuminator.py +2 -11
  43. simtools/applications/simulate_pedestals.py +1 -5
  44. simtools/applications/simulate_prod.py +8 -11
  45. simtools/applications/simulate_prod_htcondor_generator.py +1 -1
  46. simtools/applications/submit_array_layouts.py +2 -4
  47. simtools/applications/submit_data_from_external.py +2 -1
  48. simtools/applications/submit_model_parameter_from_external.py +1 -3
  49. simtools/applications/validate_camera_efficiency.py +28 -28
  50. simtools/applications/validate_camera_fov.py +0 -1
  51. simtools/applications/validate_cumulative_psf.py +1 -5
  52. simtools/applications/validate_optics.py +2 -14
  53. simtools/atmosphere.py +83 -0
  54. simtools/camera/camera_efficiency.py +171 -53
  55. simtools/camera/single_photon_electron_spectrum.py +8 -7
  56. simtools/configuration/commandline_parser.py +82 -11
  57. simtools/configuration/configurator.py +6 -11
  58. simtools/constants.py +5 -0
  59. simtools/corsika/corsika_config.py +100 -202
  60. simtools/corsika/corsika_histograms.py +561 -1708
  61. simtools/corsika/primary_particle.py +1 -1
  62. simtools/data_model/metadata_collector.py +5 -2
  63. simtools/data_model/metadata_model.py +0 -4
  64. simtools/data_model/model_data_writer.py +59 -64
  65. simtools/data_model/schema.py +2 -0
  66. simtools/data_model/validate_data.py +1 -3
  67. simtools/db/db_handler.py +23 -10
  68. simtools/db/mongo_db.py +2 -2
  69. simtools/dependencies.py +81 -38
  70. simtools/io/ascii_handler.py +55 -5
  71. simtools/io/io_handler.py +23 -12
  72. simtools/io/table_handler.py +1 -1
  73. simtools/job_execution/job_manager.py +154 -79
  74. simtools/job_execution/process_pool.py +137 -0
  75. simtools/layout/array_layout.py +4 -13
  76. simtools/layout/array_layout_utils.py +348 -57
  77. simtools/model/array_model.py +23 -63
  78. simtools/model/calibration_model.py +4 -8
  79. simtools/model/legacy_model_parameter.py +134 -0
  80. simtools/model/model_parameter.py +147 -86
  81. simtools/model/model_utils.py +40 -6
  82. simtools/model/site_model.py +4 -8
  83. simtools/model/telescope_model.py +10 -16
  84. simtools/production_configuration/derive_corsika_limits.py +6 -11
  85. simtools/production_configuration/interpolation_handler.py +16 -16
  86. simtools/ray_tracing/incident_angles.py +92 -17
  87. simtools/ray_tracing/mirror_panel_psf.py +338 -222
  88. simtools/ray_tracing/psf_analysis.py +62 -48
  89. simtools/ray_tracing/psf_parameter_optimisation.py +3 -3
  90. simtools/ray_tracing/ray_tracing.py +43 -25
  91. simtools/reporting/docs_auto_report_generator.py +8 -13
  92. simtools/reporting/docs_read_parameters.py +2 -8
  93. simtools/runners/corsika_runner.py +52 -195
  94. simtools/runners/corsika_simtel_runner.py +77 -108
  95. simtools/runners/runner_services.py +214 -213
  96. simtools/runners/simtel_runner.py +27 -160
  97. simtools/runners/simtools_runner.py +11 -73
  98. simtools/schemas/application_workflow.metaschema.yml +8 -0
  99. simtools/settings.py +173 -0
  100. simtools/{io/eventio_handler.py → sim_events/file_info.py} +3 -3
  101. simtools/{simtel/simtel_io_event_histograms.py → sim_events/histograms.py} +25 -15
  102. simtools/{simtel/simtel_io_event_reader.py → sim_events/reader.py} +20 -17
  103. simtools/{simtel/simtel_io_event_writer.py → sim_events/writer.py} +84 -25
  104. simtools/simtel/pulse_shapes.py +7 -2
  105. simtools/simtel/simtel_config_writer.py +79 -91
  106. simtools/simtel/simtel_seeds.py +184 -0
  107. simtools/simtel/simtel_table_reader.py +6 -4
  108. simtools/simtel/simulator_array.py +114 -109
  109. simtools/simtel/simulator_camera_efficiency.py +68 -46
  110. simtools/simtel/simulator_light_emission.py +164 -132
  111. simtools/simtel/simulator_ray_tracing.py +80 -71
  112. simtools/simulator.py +137 -355
  113. simtools/telescope_trigger_rates.py +3 -4
  114. simtools/testing/assertions.py +84 -33
  115. simtools/testing/configuration.py +1 -2
  116. simtools/testing/helpers.py +2 -3
  117. simtools/testing/log_inspector.py +1 -0
  118. simtools/testing/sim_telarray_metadata.py +14 -12
  119. simtools/testing/validate_output.py +121 -42
  120. simtools/utils/general.py +43 -17
  121. simtools/utils/geometry.py +0 -77
  122. simtools/utils/names.py +5 -5
  123. simtools/utils/random.py +36 -0
  124. simtools/visualization/legend_handlers.py +7 -6
  125. simtools/visualization/plot_array_layout.py +91 -16
  126. simtools/visualization/plot_corsika_histograms.py +145 -605
  127. simtools/visualization/plot_incident_angles.py +48 -1
  128. simtools/visualization/plot_mirrors.py +1 -4
  129. simtools/visualization/plot_pixels.py +2 -4
  130. simtools/visualization/plot_psf.py +160 -19
  131. simtools/visualization/plot_simtel_event_histograms.py +4 -4
  132. simtools/visualization/plot_simtel_events.py +6 -11
  133. simtools/visualization/plot_tables.py +8 -19
  134. simtools/visualization/visualize.py +22 -2
  135. simtools/applications/db_development_tools/write_array_elements_positions_to_repository.py +0 -160
  136. simtools/applications/print_version.py +0 -53
  137. simtools/io/hdf5_handler.py +0 -139
  138. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/top_level.txt +0 -0
@@ -1,297 +1,413 @@
1
- """Mirror panel PSF calculation."""
1
+ """Mirror panel PSF calculation with per-mirror PSF diameter optimization."""
2
2
 
3
+ import json
3
4
  import logging
5
+ import os
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
4
8
 
5
- import astropy.units as u
6
9
  import numpy as np
7
- from astropy.table import QTable, Table
10
+ from astropy.table import Table
8
11
 
9
- import simtools.data_model.model_data_writer as writer
10
12
  import simtools.utils.general as gen
11
- from simtools.data_model.metadata_collector import MetadataCollector
13
+ from simtools.data_model import model_data_writer
14
+ from simtools.job_execution.process_pool import process_pool_map_ordered
12
15
  from simtools.model.model_utils import initialize_simulation_models
13
16
  from simtools.ray_tracing.ray_tracing import RayTracing
17
+ from simtools.utils import names
18
+ from simtools.visualization import plot_psf
19
+
20
+
21
+ @dataclass
22
+ class MirrorOptimizationResult:
23
+ """Dataclass to store the result of a single mirror RNDA optimization."""
24
+
25
+ mirror: int # Zero-based mirror index
26
+ measured_psf_mm: float # Measured PSF diameter in mm
27
+ optimized_rnda: list[float] # Optimized RNDA values [sigma1, fraction2, sigma2]
28
+ simulated_psf_mm: float # Simulated PSF diameter after optimization
29
+ percentage_diff: float # Absolute percentage difference
30
+ containment_fraction: float # PSF containment fraction used for the diameter
31
+
32
+
33
+ def _optimize_single_mirror_worker(args):
34
+ """Worker wrapper for optimizing a single mirror in parallel."""
35
+ instance, mirror_idx, measured = args
36
+ return instance.optimize_single_mirror(mirror_idx, measured)
14
37
 
15
38
 
16
39
  class MirrorPanelPSF:
17
40
  """
18
- Mirror panel PSF and random reflection angle calculation.
41
+ Mirror panel PSF and random reflection angle (RNDA) calculation.
19
42
 
20
- This class is used to derive the random reflection angle for the mirror panels in the telescope.
21
-
22
- Known limitations: single Gaussian PSF model, no support for multiple PSF components (as allowed
23
- in the model parameters).
43
+ This class derives the RNDA for mirror panels by optimizing
44
+ per-mirror PSF diameters (at a given containment fraction)
45
+ using a percentage difference metric.
46
+ Optimization uses a gradient descent with finite-difference gradients.
24
47
 
25
48
  Parameters
26
49
  ----------
27
- label: str
50
+ label : str
28
51
  Application label.
29
- args_dict: dict
30
- Dictionary with input arguments.
31
- db_config:
32
- Dictionary with database configuration.
52
+ args_dict : dict
53
+ Dictionary with input arguments, e.g. site, telescope, data path, model version.
33
54
  """
34
55
 
35
- def __init__(self, label, args_dict, db_config):
36
- """Initialize the MirrorPanelPSF class."""
37
- self._logger = logging.getLogger(__name__)
38
- self._logger.debug("Initializing MirrorPanelPSF")
56
+ # Hard guardrails
57
+ GRAD_CLIP = 1e4
58
+ MAX_LOG_STEP = 0.25
59
+ MAX_FRAC_STEP = 0.1
60
+ MAX_ITER = 100
39
61
 
62
+ def __init__(self, label, args_dict):
63
+ self._logger = logging.getLogger(__name__)
64
+ self.label = label
40
65
  self.args_dict = args_dict
41
- self.telescope_model, self.site_model = self._define_telescope_model(label, db_config)
42
66
 
43
- if self.args_dict["test"]:
44
- self.args_dict["number_of_mirrors_to_test"] = 2
67
+ self.fraction = args_dict.get("fraction", 0.8)
68
+
69
+ self.telescope_model, self.site_model, _ = initialize_simulation_models(
70
+ label=label,
71
+ site=args_dict["site"],
72
+ telescope_name=args_dict["telescope"],
73
+ model_version=args_dict["model_version"],
74
+ )
45
75
 
46
- if self.args_dict["psf_measurement"]:
47
- self._get_psf_containment()
48
- if not self.args_dict["psf_measurement_containment_mean"]:
49
- raise ValueError("Missing PSF measurement")
76
+ self.measured_data = self._load_measured_data()
77
+ self.rnda_start = list(
78
+ self.telescope_model.get_parameter_value("mirror_reflection_random_angle")
79
+ )
50
80
 
51
- self.mean_d80 = None
52
- self.sig_d80 = None
53
- self.rnda_start = self._get_starting_value()
81
+ self.per_mirror_results = []
54
82
  self.rnda_opt = None
55
- self.results_rnda = []
56
- self.results_mean = []
57
- self.results_sig = []
83
+ self.final_percentage_diff = None
58
84
 
59
- def _define_telescope_model(self, label, db_config):
85
+ def _load_measured_data(self):
60
86
  """
61
- Define telescope model.
62
-
63
- This includes updating the configuration with mirror list and/or random focal length given
64
- as input.
87
+ Load measured PSF diameter from ECSV file.
65
88
 
66
89
  Returns
67
90
  -------
68
- tel : TelescopeModel
69
- The telescope model.
70
- site_model : SiteModel
71
- The site model.
91
+ astropy.table.Column
92
+ Column containing PSF diameter values in mm.
72
93
  """
73
- tel_model, site_model, _ = initialize_simulation_models(
74
- label=label,
75
- db_config=db_config,
76
- site=self.args_dict["site"],
77
- telescope_name=self.args_dict["telescope"],
78
- model_version=self.args_dict["model_version"],
79
- )
80
- if self.args_dict["mirror_list"] is not None:
81
- mirror_list_file = gen.find_file(
82
- name=self.args_dict["mirror_list"], loc=self.args_dict["model_path"]
83
- )
84
- tel_model.overwrite_model_parameter("mirror_list", self.args_dict["mirror_list"])
85
- tel_model.overwrite_model_file("mirror_list", mirror_list_file)
86
- if self.args_dict["random_focal_length"] is not None:
87
- tel_model.overwrite_model_parameter(
88
- "random_focal_length", str(self.args_dict["random_focal_length"])
89
- )
94
+ data_file = gen.find_file(self.args_dict["data"], self.args_dict.get("model_path", "."))
95
+ table = Table.read(data_file)
96
+ if "psf_opt" in table.colnames:
97
+ return table["psf_opt"]
98
+ if "d80" in table.colnames:
99
+ fraction = float(self.args_dict.get("fraction", 0.8))
100
+ if not np.isclose(fraction, 0.8, rtol=1e-09, atol=1e-09):
101
+ self._logger.warning(
102
+ "Input table provides 'd80' column, but --fraction=%.3f was requested. "
103
+ "Make sure the measured column matches the selected containment fraction.",
104
+ fraction,
105
+ )
106
+ return table["d80"]
107
+ raise ValueError("Data file must contain either 'psf_opt' or 'd80' column")
90
108
 
91
- return tel_model, site_model
109
+ def _simulate_single_mirror_psf(self, mirror_idx, rnda_values):
110
+ """
111
+ Simulate a single mirror and return its PSF diameter.
92
112
 
93
- def _get_psf_containment(self):
94
- """Read measured single-mirror point-spread function from file and return mean and sigma."""
95
- # If this is a test, read just the first few lines since we only simulate those mirrors
96
- data_end = (
97
- self.args_dict["number_of_mirrors_to_test"] + 1 if self.args_dict["test"] else None
98
- )
99
- _psf_list = Table.read(
100
- self.args_dict["psf_measurement"], format="ascii.ecsv", data_end=data_end
113
+ Parameters
114
+ ----------
115
+ mirror_idx : int
116
+ Zero-based index of the mirror.
117
+ rnda_values : list of float
118
+ RNDA values [sigma1, fraction2, sigma2].
119
+
120
+ Returns
121
+ -------
122
+ float
123
+ Simulated PSF diameter in mm.
124
+ """
125
+ fraction = self.fraction
126
+ self.telescope_model.overwrite_model_parameter(
127
+ "mirror_reflection_random_angle", rnda_values
101
128
  )
102
- try:
103
- self.args_dict["psf_measurement_containment_mean"] = np.nanmean(
104
- np.array(_psf_list["psf_opt"].to("cm").value)
105
- )
106
- self.args_dict["psf_measurement_containment_sigma"] = np.nanstd(
107
- np.array(_psf_list["psf_opt"].to("cm").value)
108
- )
109
- except KeyError as exc:
110
- raise KeyError(
111
- f"Missing column psf measurement (psf_opt) in {self.args_dict['psf_measurement']}"
112
- ) from exc
113
129
 
114
- self._logger.info(
115
- f"Determined PSF containment to {self.args_dict['psf_measurement_containment_mean']:.4}"
116
- f" +- {self.args_dict['psf_measurement_containment_sigma']:.4} cm"
130
+ rt_label = f"{self.label}_m{mirror_idx}"
131
+ ray = RayTracing(
132
+ telescope_model=self.telescope_model,
133
+ site_model=self.site_model,
134
+ label=rt_label,
135
+ single_mirror_mode=True,
136
+ mirror_numbers=[mirror_idx],
117
137
  )
138
+ ray.simulate(test=self.args_dict.get("test", False), force=True)
139
+ ray.analyze(force=True, containment_fraction=fraction)
140
+
141
+ return float(ray.get_psf_mm())
142
+
143
+ @staticmethod
144
+ def _signed_pct_diff(measured, simulated):
145
+ """Compute signed percentage difference."""
146
+ if measured <= 0:
147
+ raise ValueError("Measured PSF diameter must be positive")
148
+ return 100.0 * (simulated - measured) / measured
118
149
 
119
- def derive_random_reflection_angle(self, save_figures=False):
150
+ def _evaluate(self, mirror_idx, measured, rnda):
120
151
  """
121
- Minimize the difference between measured and simulated PSF for reflection angle.
152
+ Evaluate the objective function for a mirror.
122
153
 
123
- Main loop of the optimization process. The method iterates over different values of the
124
- random reflection angle until the difference in the mean value of the D80 containment
125
- is minimal.
154
+ Parameters
155
+ ----------
156
+ mirror_idx : int
157
+ Mirror index (0-based).
158
+ measured : float
159
+ Measured PSF diameter (mm).
160
+ rnda : list of float
161
+ Current RNDA values.
162
+
163
+ Returns
164
+ -------
165
+ tuple of (float, float, float)
166
+ Simulated PSF diameter, signed percentage difference, squared percentage difference.
126
167
  """
127
- if self.args_dict["no_tuning"]:
128
- self.rnda_opt = self.rnda_start
129
- else:
130
- self._optimize_reflection_angle()
168
+ sim = self._simulate_single_mirror_psf(mirror_idx, rnda)
169
+ pct = self._signed_pct_diff(measured, sim)
170
+ return sim, pct, pct * pct
131
171
 
132
- self.mean_d80, self.sig_d80 = self.run_simulations_and_analysis(
133
- self.rnda_opt, save_figures=save_figures
134
- )
172
+ @staticmethod
173
+ def _rnda_bounds():
174
+ """
175
+ Get allowed bounds for RNDA parameters.
135
176
 
136
- def _optimize_reflection_angle(self, step_size=0.1, max_iteration=100):
177
+ Returns
178
+ -------
179
+ list of tuple of float
180
+ [(min1, max1), (min2, max2), (min3, max3)]
137
181
  """
138
- Optimize the random reflection angle to minimize the difference in D80 containment.
182
+ schema = names.model_parameters()["mirror_reflection_random_angle"]["data"]
183
+ return [
184
+ (max(schema[0]["allowed_range"]["min"], 1e-12), schema[0]["allowed_range"]["max"]),
185
+ (schema[1]["allowed_range"]["min"], schema[1]["allowed_range"]["max"]),
186
+ (max(schema[2]["allowed_range"]["min"], 1e-12), schema[2]["allowed_range"]["max"]),
187
+ ]
188
+
189
+ def optimize_single_mirror(self, mirror_idx, measured_psf):
190
+ """
191
+ Optimize RNDA for a single mirror using gradient descent.
139
192
 
140
193
  Parameters
141
194
  ----------
142
- step_size: float
143
- Initial step size for optimization.
144
- max_iteration: int
145
- Maximum number of iterations.
146
-
147
- Raises
148
- ------
149
- ValueError
150
- If the optimization reaches the maximum number of iterations without converging.
195
+ mirror_idx : int
196
+ Zero-based mirror index.
197
+ measured_psf : float
198
+ Measured PSF diameter in mm.
151
199
 
200
+ Returns
201
+ -------
202
+ MirrorOptimizationResult
203
+ Optimization results for the mirror.
152
204
  """
153
- relative_tolerance_d80 = self.args_dict["rtol_psf_containment"]
205
+ threshold_pct = 100 * float(self.args_dict.get("threshold", 0.05))
206
+ learning_rate = float(self.args_dict.get("learning_rate", 1e-3))
207
+ bounds = self._rnda_bounds()
208
+ fraction = self.fraction
209
+
210
+ rnda = list(self.rnda_start)
211
+ sim, pct, obj = self._evaluate(mirror_idx, measured_psf, rnda)
212
+ best = {"rnda": list(rnda), "sim": sim, "pct": abs(pct), "obj": obj}
213
+
154
214
  self._logger.info(
155
- "Optimizing random reflection angle "
156
- f"(relative tolerance = {relative_tolerance_d80}, "
157
- f"step size = {step_size}, max iteration = {max_iteration})"
215
+ "Mirror %d | initial PSF %.3f mm (f=%.2f) | pct %.2f%%",
216
+ mirror_idx,
217
+ sim,
218
+ fraction,
219
+ pct,
158
220
  )
159
221
 
160
- def collect_results(rnda, mean, sig):
161
- self.results_rnda.append(rnda)
162
- self.results_mean.append(mean)
163
- self.results_sig.append(sig)
222
+ for iteration in range(self.MAX_ITER):
223
+ old_rnda = list(rnda)
224
+ for param_idx, (param_value, param_bounds) in enumerate(zip(old_rnda, bounds)):
225
+ rnda[param_idx] = self._update_single_rnda_parameter(
226
+ mirror_idx=mirror_idx,
227
+ measured_psf=measured_psf,
228
+ rnda=rnda,
229
+ param_idx=param_idx,
230
+ param_value=param_value,
231
+ param_bounds=param_bounds,
232
+ learning_rate=learning_rate,
233
+ )
164
234
 
165
- reference_d80 = self.args_dict["psf_measurement_containment_mean"]
166
- rnda = self.rnda_start
167
- prev_error_d80 = float("inf")
168
- iteration = 0
235
+ sim, pct, obj = self._evaluate(mirror_idx, measured_psf, rnda)
236
+ pct = abs(pct)
169
237
 
170
- while True:
171
- mean_d80, sig_d80 = self.run_simulations_and_analysis(rnda)
172
- error_d80 = abs(1 - mean_d80 / reference_d80)
173
- collect_results(rnda, mean_d80, sig_d80)
238
+ old_rnda_str = "[" + ", ".join(f"{v:.4g}" for v in old_rnda) + "]"
239
+ rnda_str = "[" + ", ".join(f"{v:.4g}" for v in rnda) + "]"
174
240
 
175
- if error_d80 < relative_tolerance_d80:
176
- break
241
+ self._logger.info(
242
+ "Iter %d | rnda %s -> %s | pct %.2f -> %.2f | lr %.2g",
243
+ iteration + 1,
244
+ old_rnda_str,
245
+ rnda_str,
246
+ best["pct"],
247
+ pct,
248
+ learning_rate,
249
+ )
177
250
 
178
- if mean_d80 < reference_d80:
179
- rnda += step_size * self.rnda_start
251
+ if obj < best["obj"]:
252
+ best.update(rnda=list(rnda), sim=sim, pct=pct, obj=obj)
253
+ learning_rate *= 1.1
180
254
  else:
181
- rnda -= step_size * self.rnda_start
182
-
183
- if error_d80 >= prev_error_d80:
184
- step_size = step_size / 2
185
- prev_error_d80 = error_d80
186
- iteration += 1
187
- if iteration > max_iteration:
188
- raise ValueError(
189
- f"Maximum iterations ({max_iteration}) reached without convergence."
190
- )
255
+ rnda = list(old_rnda)
256
+ learning_rate *= 0.5
191
257
 
192
- self.rnda_opt = rnda
193
-
194
- def _get_starting_value(self):
195
- """Get optimization starting value from command line or previous model."""
196
- if self.args_dict["rnda"] != 0:
197
- rnda_start = self.args_dict["rnda"]
198
- else:
199
- rnda_start = self.telescope_model.get_parameter_value("mirror_reflection_random_angle")[
200
- 0
201
- ]
258
+ if best["pct"] <= threshold_pct or learning_rate < 1e-12:
259
+ break
202
260
 
203
- self._logger.info(f"Start value for mirror_reflection_random_angle: {rnda_start} deg")
204
- return rnda_start
261
+ return MirrorOptimizationResult(
262
+ mirror=mirror_idx,
263
+ measured_psf_mm=float(measured_psf),
264
+ optimized_rnda=best["rnda"],
265
+ simulated_psf_mm=best["sim"],
266
+ percentage_diff=best["pct"],
267
+ containment_fraction=fraction,
268
+ )
205
269
 
206
- def run_simulations_and_analysis(self, rnda, save_figures=False):
270
+ def _update_single_rnda_parameter(
271
+ self,
272
+ mirror_idx,
273
+ measured_psf,
274
+ rnda,
275
+ param_idx,
276
+ param_value,
277
+ param_bounds,
278
+ learning_rate,
279
+ ):
207
280
  """
208
- Run ray tracing simulations and analysis for one given value of rnda.
281
+ Update a single RNDA parameter using finite-difference gradient descent.
209
282
 
210
283
  Parameters
211
284
  ----------
212
- rnda: float
213
- Random reflection angle in degrees.
214
- save_figures: bool
215
- Save figures.
285
+ mirror_idx : int
286
+ measured_psf : float
287
+ rnda : list of float
288
+ param_idx : int
289
+ param_value : float
290
+ param_bounds : tuple of float
291
+ learning_rate : float
216
292
 
217
293
  Returns
218
294
  -------
219
- mean_d80: float
220
- Mean value of D80 in cm.
221
- sig_d80: float
222
- Standard deviation of D80 in cm.
295
+ float
296
+ Updated RNDA parameter value.
223
297
  """
224
- self.telescope_model.overwrite_model_parameter("mirror_reflection_random_angle", rnda)
225
- ray = RayTracing(
226
- telescope_model=self.telescope_model,
227
- site_model=self.site_model,
228
- simtel_path=self.args_dict.get("simtel_path", None),
229
- single_mirror_mode=True,
230
- mirror_numbers=(
231
- list(range(1, self.args_dict["number_of_mirrors_to_test"] + 1))
232
- if self.args_dict["test"]
233
- else "all"
234
- ),
235
- use_random_focal_length=self.args_dict["use_random_focal_length"],
236
- random_focal_length_seed=self.args_dict.get("random_focal_length_seed"),
237
- )
238
- ray.simulate(test=self.args_dict["test"], force=True) # force has to be True, always
239
- ray.analyze(force=True)
240
- if save_figures:
241
- ray.plot("d80_cm", save=True, d80=self.args_dict["psf_measurement_containment_mean"])
242
-
243
- return (
244
- ray.get_mean("d80_cm").to(u.cm).value,
245
- ray.get_std_dev("d80_cm").to(u.cm).value,
246
- )
298
+ param_min, param_max = param_bounds
299
+ is_log_param = param_idx != 1
300
+ epsilon = max(1e-6, 0.05 * param_value) if is_log_param else 0.05
247
301
 
248
- def print_results(self):
249
- """Print results to stdout."""
250
- containment_fraction_percent = int(self.args_dict["containment_fraction"] * 100)
302
+ rnda[param_idx] = min(param_max, param_value + epsilon)
303
+ _, _, f_plus = self._evaluate(mirror_idx, measured_psf, rnda)
251
304
 
252
- print(f"\nMeasured D{containment_fraction_percent}:")
253
- if self.args_dict["psf_measurement_containment_sigma"] is not None:
254
- print(
255
- f"Mean = {self.args_dict['psf_measurement_containment_mean']:.3f} cm, "
256
- f"StdDev = {self.args_dict['psf_measurement_containment_sigma']:.3f} cm"
257
- )
258
- else:
259
- print(f"Mean = {self.args_dict['psf_measurement_containment_mean']:.3f} cm")
260
- print(f"\nSimulated D{containment_fraction_percent}:")
261
- print(f"Mean = {self.mean_d80:.3f} cm, StdDev = {self.sig_d80:.3f} cm")
262
- print("\nmirror_random_reflection_angle")
263
- print(f"Previous value = {self.rnda_start:.6f}")
264
- print(f"New value = {self.rnda_opt:.6f}\n")
305
+ rnda[param_idx] = max(param_min, param_value - epsilon)
306
+ _, _, f_minus = self._evaluate(mirror_idx, measured_psf, rnda)
265
307
 
266
- def write_optimization_data(self):
308
+ rnda[param_idx] = param_value
309
+ gradient = (f_plus - f_minus) / (2 * epsilon)
310
+
311
+ # Clip the gradient to avoid excessively large steps.
312
+ if is_log_param:
313
+ gradient = np.clip(gradient * param_value, -self.GRAD_CLIP, self.GRAD_CLIP)
314
+ step = np.clip(-learning_rate * gradient, -self.MAX_LOG_STEP, self.MAX_LOG_STEP)
315
+ # clip the parameters so they are within allowed range
316
+ return np.clip(param_value * np.exp(step), param_min, param_max)
317
+
318
+ gradient = np.clip(gradient, -self.GRAD_CLIP, self.GRAD_CLIP)
319
+ step = np.clip(-learning_rate * gradient, -self.MAX_FRAC_STEP, self.MAX_FRAC_STEP)
320
+ return np.clip(param_value + step, param_min, param_max)
321
+
322
+ def optimize_with_gradient_descent(self):
267
323
  """
268
- Write optimization results to an astropy table (ecsv file).
324
+ Optimize all mirrors in parallel using process pool.
269
325
 
270
- Used mostly for debugging of the optimization process.
271
- The first entry of the table is the best fit result.
326
+ Sets
327
+ ----
328
+ self.per_mirror_results : list of dict
329
+ self.rnda_opt : list of float
330
+ self.final_percentage_diff : float
272
331
  """
273
- containment_fraction_percent = int(self.args_dict["containment_fraction"] * 100)
274
-
275
- result_table = QTable(
276
- [
277
- [True] + [False] * len(self.results_rnda),
278
- [self.rnda_opt, *self.results_rnda] * u.deg,
279
- ([0.0] * (len(self.results_rnda) + 1)),
280
- ([0.0] * (len(self.results_rnda) + 1)) * u.deg,
281
- [self.mean_d80, *self.results_mean] * u.cm,
282
- [self.sig_d80, *self.results_sig] * u.cm,
283
- ],
284
- names=(
285
- "best_fit",
286
- "mirror_reflection_random_angle_sigma1",
287
- "mirror_reflection_random_angle_fraction2",
288
- "mirror_reflection_random_angle_sigma2",
289
- f"containment_radius_D{containment_fraction_percent}",
290
- f"containment_radius_sigma_D{containment_fraction_percent}",
291
- ),
332
+ n_mirrors = len(self.measured_data)
333
+ if self.args_dict.get("test"):
334
+ n_mirrors = min(n_mirrors, self.args_dict.get("number_of_mirrors_to_test"))
335
+
336
+ n_workers = int(self.args_dict.get("n_workers") or os.cpu_count())
337
+ parent = MirrorPanelPSF(self.label, dict(self.args_dict))
338
+ worker_args = [(parent, i, parent.measured_data[i]) for i in range(n_mirrors)]
339
+
340
+ self.per_mirror_results = process_pool_map_ordered(
341
+ _optimize_single_mirror_worker,
342
+ worker_args,
343
+ max_workers=n_workers,
344
+ mp_start_method="fork",
292
345
  )
293
- writer.ModelDataWriter.dump(
294
- args_dict=self.args_dict,
295
- metadata=MetadataCollector(args_dict=self.args_dict),
296
- product_data=result_table,
346
+
347
+ self.rnda_opt = np.mean(
348
+ [r.optimized_rnda for r in self.per_mirror_results], axis=0
349
+ ).tolist()
350
+
351
+ self.final_percentage_diff = float(
352
+ np.mean([r.percentage_diff for r in self.per_mirror_results])
297
353
  )
354
+
355
+ def write_optimization_data(self):
356
+ """Write optimization results and optionally export as a model parameter."""
357
+ output_dir = Path(self.args_dict.get("output_path", "."))
358
+ telescope = self.args_dict.get("telescope")
359
+ parameter_version = self.args_dict.get("parameter_version")
360
+ parameter_name = "mirror_reflection_random_angle"
361
+ parameter_output_path = output_dir / str(telescope)
362
+ parameter_output_path.mkdir(parents=True, exist_ok=True)
363
+ output_file = parameter_output_path / "per_mirror_rnda.json"
364
+
365
+ per_mirror_results_out = []
366
+ for result in self.per_mirror_results:
367
+ result_dict = asdict(result)
368
+ for k, v in result_dict.items():
369
+ if k == "optimized_rnda":
370
+ result_dict[k] = [float(f"{x:.4f}") for x in v]
371
+ elif isinstance(v, (int, float)):
372
+ result_dict[k] = float(f"{v:.4f}")
373
+ per_mirror_results_out.append(result_dict)
374
+
375
+ output_file.write_text(
376
+ json.dumps(
377
+ {
378
+ "telescope": telescope,
379
+ "model_version": self.args_dict.get("model_version"),
380
+ "per_mirror_results": per_mirror_results_out,
381
+ },
382
+ indent=2,
383
+ ),
384
+ encoding="utf-8",
385
+ )
386
+ self._logger.info("Results written to %s", output_file)
387
+
388
+ # Export averaged RNDA
389
+ if telescope and parameter_version and self.rnda_opt is not None:
390
+ try:
391
+ model_data_writer.ModelDataWriter.dump_model_parameter(
392
+ parameter_name=parameter_name,
393
+ value=[float(f"{v:.4f}") for v in self.rnda_opt],
394
+ instrument=str(telescope),
395
+ parameter_version=str(parameter_version),
396
+ output_file=f"{parameter_name}-{parameter_version}.json",
397
+ output_path=parameter_output_path,
398
+ unit=["deg", "dimensionless", "deg"],
399
+ )
400
+ self._logger.info(
401
+ "Exported model parameter %s (%s) to %s",
402
+ parameter_name,
403
+ parameter_version,
404
+ parameter_output_path,
405
+ )
406
+ except (OSError, ValueError, TypeError) as e:
407
+ self._logger.warning("Failed to export model parameter %s: %s", parameter_name, e)
408
+
409
+ def write_psf_histogram(self):
410
+ """Plot histogram of measured vs simulated psf values."""
411
+ measured = [r.measured_psf_mm for r in self.per_mirror_results]
412
+ simulated = [r.simulated_psf_mm for r in self.per_mirror_results]
413
+ return plot_psf.plot_psf_histogram(measured, simulated, self.args_dict)