gammasimtools 0.26.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 (70) hide show
  1. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/METADATA +5 -1
  2. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/RECORD +70 -66
  3. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/WHEEL +1 -1
  4. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/entry_points.txt +1 -1
  5. simtools/_version.py +2 -2
  6. simtools/applications/convert_geo_coordinates_of_array_elements.py +2 -1
  7. simtools/applications/db_get_array_layouts_from_db.py +1 -1
  8. simtools/applications/{calculate_incident_angles.py → derive_incident_angle.py} +16 -16
  9. simtools/applications/derive_mirror_rnda.py +111 -177
  10. simtools/applications/generate_corsika_histograms.py +38 -1
  11. simtools/applications/generate_regular_arrays.py +73 -36
  12. simtools/applications/simulate_flasher.py +3 -13
  13. simtools/applications/simulate_illuminator.py +2 -10
  14. simtools/applications/simulate_pedestals.py +1 -1
  15. simtools/applications/simulate_prod.py +8 -7
  16. simtools/applications/submit_data_from_external.py +2 -1
  17. simtools/applications/validate_camera_efficiency.py +28 -27
  18. simtools/applications/validate_cumulative_psf.py +1 -3
  19. simtools/applications/validate_optics.py +2 -1
  20. simtools/atmosphere.py +83 -0
  21. simtools/camera/camera_efficiency.py +171 -48
  22. simtools/camera/single_photon_electron_spectrum.py +6 -6
  23. simtools/configuration/commandline_parser.py +47 -9
  24. simtools/constants.py +5 -0
  25. simtools/corsika/corsika_config.py +88 -185
  26. simtools/corsika/corsika_histograms.py +246 -69
  27. simtools/data_model/model_data_writer.py +46 -49
  28. simtools/data_model/schema.py +2 -0
  29. simtools/db/db_handler.py +4 -2
  30. simtools/db/mongo_db.py +2 -2
  31. simtools/io/ascii_handler.py +51 -3
  32. simtools/io/io_handler.py +23 -12
  33. simtools/job_execution/job_manager.py +154 -79
  34. simtools/job_execution/process_pool.py +137 -0
  35. simtools/layout/array_layout.py +0 -1
  36. simtools/layout/array_layout_utils.py +143 -21
  37. simtools/model/array_model.py +22 -50
  38. simtools/model/calibration_model.py +4 -4
  39. simtools/model/model_parameter.py +123 -73
  40. simtools/model/model_utils.py +40 -1
  41. simtools/model/site_model.py +4 -4
  42. simtools/model/telescope_model.py +4 -5
  43. simtools/ray_tracing/incident_angles.py +87 -6
  44. simtools/ray_tracing/mirror_panel_psf.py +337 -217
  45. simtools/ray_tracing/psf_analysis.py +57 -42
  46. simtools/ray_tracing/psf_parameter_optimisation.py +3 -2
  47. simtools/ray_tracing/ray_tracing.py +37 -10
  48. simtools/runners/corsika_runner.py +52 -191
  49. simtools/runners/corsika_simtel_runner.py +74 -100
  50. simtools/runners/runner_services.py +214 -213
  51. simtools/runners/simtel_runner.py +27 -155
  52. simtools/runners/simtools_runner.py +9 -69
  53. simtools/schemas/application_workflow.metaschema.yml +8 -0
  54. simtools/settings.py +19 -0
  55. simtools/simtel/simtel_config_writer.py +0 -55
  56. simtools/simtel/simtel_seeds.py +184 -0
  57. simtools/simtel/simulator_array.py +115 -103
  58. simtools/simtel/simulator_camera_efficiency.py +66 -42
  59. simtools/simtel/simulator_light_emission.py +110 -123
  60. simtools/simtel/simulator_ray_tracing.py +78 -63
  61. simtools/simulator.py +135 -346
  62. simtools/testing/sim_telarray_metadata.py +13 -11
  63. simtools/testing/validate_output.py +87 -19
  64. simtools/utils/general.py +6 -17
  65. simtools/utils/random.py +36 -0
  66. simtools/visualization/plot_corsika_histograms.py +2 -0
  67. simtools/visualization/plot_incident_angles.py +48 -1
  68. simtools/visualization/plot_psf.py +160 -18
  69. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/licenses/LICENSE +0 -0
  70. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/top_level.txt +0 -0
@@ -1,293 +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.
52
+ args_dict : dict
53
+ Dictionary with input arguments, e.g. site, telescope, data path, model version.
31
54
  """
32
55
 
56
+ # Hard guardrails
57
+ GRAD_CLIP = 1e4
58
+ MAX_LOG_STEP = 0.25
59
+ MAX_FRAC_STEP = 0.1
60
+ MAX_ITER = 100
61
+
33
62
  def __init__(self, label, args_dict):
34
- """Initialize the MirrorPanelPSF class."""
35
63
  self._logger = logging.getLogger(__name__)
36
- self._logger.debug("Initializing MirrorPanelPSF")
37
-
64
+ self.label = label
38
65
  self.args_dict = args_dict
39
- self.telescope_model, self.site_model = self._define_telescope_model(label)
40
66
 
41
- if self.args_dict["test"]:
42
- 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
+ )
43
75
 
44
- if self.args_dict["psf_measurement"]:
45
- self._get_psf_containment()
46
- if not self.args_dict["psf_measurement_containment_mean"]:
47
- 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
+ )
48
80
 
49
- self.mean_d80 = None
50
- self.sig_d80 = None
51
- self.rnda_start = self._get_starting_value()
81
+ self.per_mirror_results = []
52
82
  self.rnda_opt = None
53
- self.results_rnda = []
54
- self.results_mean = []
55
- self.results_sig = []
83
+ self.final_percentage_diff = None
56
84
 
57
- def _define_telescope_model(self, label):
85
+ def _load_measured_data(self):
58
86
  """
59
- Define telescope model.
60
-
61
- This includes updating the configuration with mirror list and/or random focal length given
62
- as input.
87
+ Load measured PSF diameter from ECSV file.
63
88
 
64
89
  Returns
65
90
  -------
66
- tel : TelescopeModel
67
- The telescope model.
68
- site_model : SiteModel
69
- The site model.
91
+ astropy.table.Column
92
+ Column containing PSF diameter values in mm.
70
93
  """
71
- tel_model, site_model, _ = initialize_simulation_models(
72
- label=label,
73
- site=self.args_dict["site"],
74
- telescope_name=self.args_dict["telescope"],
75
- model_version=self.args_dict["model_version"],
76
- )
77
- if self.args_dict["mirror_list"] is not None:
78
- mirror_list_file = gen.find_file(
79
- name=self.args_dict["mirror_list"], loc=self.args_dict["model_path"]
80
- )
81
- tel_model.overwrite_model_parameter("mirror_list", self.args_dict["mirror_list"])
82
- tel_model.overwrite_model_file("mirror_list", mirror_list_file)
83
- if self.args_dict["random_focal_length"] is not None:
84
- tel_model.overwrite_model_parameter(
85
- "random_focal_length", str(self.args_dict["random_focal_length"])
86
- )
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")
87
108
 
88
- 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.
89
112
 
90
- def _get_psf_containment(self):
91
- """Read measured single-mirror point-spread function from file and return mean and sigma."""
92
- # If this is a test, read just the first few lines since we only simulate those mirrors
93
- data_end = (
94
- self.args_dict["number_of_mirrors_to_test"] + 1 if self.args_dict["test"] else None
95
- )
96
- _psf_list = Table.read(
97
- 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
98
128
  )
99
- try:
100
- self.args_dict["psf_measurement_containment_mean"] = np.nanmean(
101
- np.array(_psf_list["psf_opt"].to("cm").value)
102
- )
103
- self.args_dict["psf_measurement_containment_sigma"] = np.nanstd(
104
- np.array(_psf_list["psf_opt"].to("cm").value)
105
- )
106
- except KeyError as exc:
107
- raise KeyError(
108
- f"Missing column psf measurement (psf_opt) in {self.args_dict['psf_measurement']}"
109
- ) from exc
110
129
 
111
- self._logger.info(
112
- f"Determined PSF containment to {self.args_dict['psf_measurement_containment_mean']:.4}"
113
- 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],
114
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
115
149
 
116
- def derive_random_reflection_angle(self, save_figures=False):
150
+ def _evaluate(self, mirror_idx, measured, rnda):
117
151
  """
118
- Minimize the difference between measured and simulated PSF for reflection angle.
152
+ Evaluate the objective function for a mirror.
119
153
 
120
- Main loop of the optimization process. The method iterates over different values of the
121
- random reflection angle until the difference in the mean value of the D80 containment
122
- 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.
123
167
  """
124
- if self.args_dict["no_tuning"]:
125
- self.rnda_opt = self.rnda_start
126
- else:
127
- 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
128
171
 
129
- self.mean_d80, self.sig_d80 = self.run_simulations_and_analysis(
130
- self.rnda_opt, save_figures=save_figures
131
- )
172
+ @staticmethod
173
+ def _rnda_bounds():
174
+ """
175
+ Get allowed bounds for RNDA parameters.
132
176
 
133
- 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)]
134
181
  """
135
- 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.
136
192
 
137
193
  Parameters
138
194
  ----------
139
- step_size: float
140
- Initial step size for optimization.
141
- max_iteration: int
142
- Maximum number of iterations.
143
-
144
- Raises
145
- ------
146
- ValueError
147
- 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.
148
199
 
200
+ Returns
201
+ -------
202
+ MirrorOptimizationResult
203
+ Optimization results for the mirror.
149
204
  """
150
- 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
+
151
214
  self._logger.info(
152
- "Optimizing random reflection angle "
153
- f"(relative tolerance = {relative_tolerance_d80}, "
154
- 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,
155
220
  )
156
221
 
157
- def collect_results(rnda, mean, sig):
158
- self.results_rnda.append(rnda)
159
- self.results_mean.append(mean)
160
- 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
+ )
161
234
 
162
- reference_d80 = self.args_dict["psf_measurement_containment_mean"]
163
- rnda = self.rnda_start
164
- prev_error_d80 = float("inf")
165
- iteration = 0
235
+ sim, pct, obj = self._evaluate(mirror_idx, measured_psf, rnda)
236
+ pct = abs(pct)
166
237
 
167
- while True:
168
- mean_d80, sig_d80 = self.run_simulations_and_analysis(rnda)
169
- error_d80 = abs(1 - mean_d80 / reference_d80)
170
- 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) + "]"
171
240
 
172
- if error_d80 < relative_tolerance_d80:
173
- 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
+ )
174
250
 
175
- if mean_d80 < reference_d80:
176
- 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
177
254
  else:
178
- rnda -= step_size * self.rnda_start
179
-
180
- if error_d80 >= prev_error_d80:
181
- step_size = step_size / 2
182
- prev_error_d80 = error_d80
183
- iteration += 1
184
- if iteration > max_iteration:
185
- raise ValueError(
186
- f"Maximum iterations ({max_iteration}) reached without convergence."
187
- )
255
+ rnda = list(old_rnda)
256
+ learning_rate *= 0.5
188
257
 
189
- self.rnda_opt = rnda
190
-
191
- def _get_starting_value(self):
192
- """Get optimization starting value from command line or previous model."""
193
- if self.args_dict["rnda"] != 0:
194
- rnda_start = self.args_dict["rnda"]
195
- else:
196
- rnda_start = self.telescope_model.get_parameter_value("mirror_reflection_random_angle")[
197
- 0
198
- ]
258
+ if best["pct"] <= threshold_pct or learning_rate < 1e-12:
259
+ break
199
260
 
200
- self._logger.info(f"Start value for mirror_reflection_random_angle: {rnda_start} deg")
201
- 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
+ )
202
269
 
203
- 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
+ ):
204
280
  """
205
- Run ray tracing simulations and analysis for one given value of rnda.
281
+ Update a single RNDA parameter using finite-difference gradient descent.
206
282
 
207
283
  Parameters
208
284
  ----------
209
- rnda: float
210
- Random reflection angle in degrees.
211
- save_figures: bool
212
- 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
213
292
 
214
293
  Returns
215
294
  -------
216
- mean_d80: float
217
- Mean value of D80 in cm.
218
- sig_d80: float
219
- Standard deviation of D80 in cm.
295
+ float
296
+ Updated RNDA parameter value.
220
297
  """
221
- self.telescope_model.overwrite_model_parameter("mirror_reflection_random_angle", rnda)
222
- ray = RayTracing(
223
- telescope_model=self.telescope_model,
224
- site_model=self.site_model,
225
- single_mirror_mode=True,
226
- mirror_numbers=(
227
- list(range(1, self.args_dict["number_of_mirrors_to_test"] + 1))
228
- if self.args_dict["test"]
229
- else "all"
230
- ),
231
- use_random_focal_length=self.args_dict["use_random_focal_length"],
232
- random_focal_length_seed=self.args_dict.get("random_focal_length_seed"),
233
- )
234
- ray.simulate(test=self.args_dict["test"], force=True) # force has to be True, always
235
- ray.analyze(force=True)
236
- if save_figures:
237
- ray.plot("d80_cm", save=True, d80=self.args_dict["psf_measurement_containment_mean"])
238
-
239
- return (
240
- ray.get_mean("d80_cm").to(u.cm).value,
241
- ray.get_std_dev("d80_cm").to(u.cm).value,
242
- )
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
243
301
 
244
- def print_results(self):
245
- """Print results to stdout."""
246
- 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)
247
304
 
248
- print(f"\nMeasured D{containment_fraction_percent}:")
249
- if self.args_dict["psf_measurement_containment_sigma"] is not None:
250
- print(
251
- f"Mean = {self.args_dict['psf_measurement_containment_mean']:.3f} cm, "
252
- f"StdDev = {self.args_dict['psf_measurement_containment_sigma']:.3f} cm"
253
- )
254
- else:
255
- print(f"Mean = {self.args_dict['psf_measurement_containment_mean']:.3f} cm")
256
- print(f"\nSimulated D{containment_fraction_percent}:")
257
- print(f"Mean = {self.mean_d80:.3f} cm, StdDev = {self.sig_d80:.3f} cm")
258
- print("\nmirror_random_reflection_angle")
259
- print(f"Previous value = {self.rnda_start:.6f}")
260
- 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)
261
307
 
262
- 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):
263
323
  """
264
- Write optimization results to an astropy table (ecsv file).
324
+ Optimize all mirrors in parallel using process pool.
265
325
 
266
- Used mostly for debugging of the optimization process.
267
- 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
268
331
  """
269
- containment_fraction_percent = int(self.args_dict["containment_fraction"] * 100)
270
-
271
- result_table = QTable(
272
- [
273
- [True] + [False] * len(self.results_rnda),
274
- [self.rnda_opt, *self.results_rnda] * u.deg,
275
- ([0.0] * (len(self.results_rnda) + 1)),
276
- ([0.0] * (len(self.results_rnda) + 1)) * u.deg,
277
- [self.mean_d80, *self.results_mean] * u.cm,
278
- [self.sig_d80, *self.results_sig] * u.cm,
279
- ],
280
- names=(
281
- "best_fit",
282
- "mirror_reflection_random_angle_sigma1",
283
- "mirror_reflection_random_angle_fraction2",
284
- "mirror_reflection_random_angle_sigma2",
285
- f"containment_radius_D{containment_fraction_percent}",
286
- f"containment_radius_sigma_D{containment_fraction_percent}",
287
- ),
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",
288
345
  )
289
- writer.ModelDataWriter.dump(
290
- args_dict=self.args_dict,
291
- metadata=MetadataCollector(args_dict=self.args_dict),
292
- 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])
293
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)