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.
- {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/METADATA +5 -1
- {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/RECORD +70 -66
- {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/WHEEL +1 -1
- {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/entry_points.txt +1 -1
- simtools/_version.py +2 -2
- simtools/applications/convert_geo_coordinates_of_array_elements.py +2 -1
- simtools/applications/db_get_array_layouts_from_db.py +1 -1
- simtools/applications/{calculate_incident_angles.py → derive_incident_angle.py} +16 -16
- simtools/applications/derive_mirror_rnda.py +111 -177
- simtools/applications/generate_corsika_histograms.py +38 -1
- simtools/applications/generate_regular_arrays.py +73 -36
- simtools/applications/simulate_flasher.py +3 -13
- simtools/applications/simulate_illuminator.py +2 -10
- simtools/applications/simulate_pedestals.py +1 -1
- simtools/applications/simulate_prod.py +8 -7
- simtools/applications/submit_data_from_external.py +2 -1
- simtools/applications/validate_camera_efficiency.py +28 -27
- simtools/applications/validate_cumulative_psf.py +1 -3
- simtools/applications/validate_optics.py +2 -1
- simtools/atmosphere.py +83 -0
- simtools/camera/camera_efficiency.py +171 -48
- simtools/camera/single_photon_electron_spectrum.py +6 -6
- simtools/configuration/commandline_parser.py +47 -9
- simtools/constants.py +5 -0
- simtools/corsika/corsika_config.py +88 -185
- simtools/corsika/corsika_histograms.py +246 -69
- simtools/data_model/model_data_writer.py +46 -49
- simtools/data_model/schema.py +2 -0
- simtools/db/db_handler.py +4 -2
- simtools/db/mongo_db.py +2 -2
- simtools/io/ascii_handler.py +51 -3
- simtools/io/io_handler.py +23 -12
- simtools/job_execution/job_manager.py +154 -79
- simtools/job_execution/process_pool.py +137 -0
- simtools/layout/array_layout.py +0 -1
- simtools/layout/array_layout_utils.py +143 -21
- simtools/model/array_model.py +22 -50
- simtools/model/calibration_model.py +4 -4
- simtools/model/model_parameter.py +123 -73
- simtools/model/model_utils.py +40 -1
- simtools/model/site_model.py +4 -4
- simtools/model/telescope_model.py +4 -5
- simtools/ray_tracing/incident_angles.py +87 -6
- simtools/ray_tracing/mirror_panel_psf.py +337 -217
- simtools/ray_tracing/psf_analysis.py +57 -42
- simtools/ray_tracing/psf_parameter_optimisation.py +3 -2
- simtools/ray_tracing/ray_tracing.py +37 -10
- simtools/runners/corsika_runner.py +52 -191
- simtools/runners/corsika_simtel_runner.py +74 -100
- simtools/runners/runner_services.py +214 -213
- simtools/runners/simtel_runner.py +27 -155
- simtools/runners/simtools_runner.py +9 -69
- simtools/schemas/application_workflow.metaschema.yml +8 -0
- simtools/settings.py +19 -0
- simtools/simtel/simtel_config_writer.py +0 -55
- simtools/simtel/simtel_seeds.py +184 -0
- simtools/simtel/simulator_array.py +115 -103
- simtools/simtel/simulator_camera_efficiency.py +66 -42
- simtools/simtel/simulator_light_emission.py +110 -123
- simtools/simtel/simulator_ray_tracing.py +78 -63
- simtools/simulator.py +135 -346
- simtools/testing/sim_telarray_metadata.py +13 -11
- simtools/testing/validate_output.py +87 -19
- simtools/utils/general.py +6 -17
- simtools/utils/random.py +36 -0
- simtools/visualization/plot_corsika_histograms.py +2 -0
- simtools/visualization/plot_incident_angles.py +48 -1
- simtools/visualization/plot_psf.py +160 -18
- {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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.
|
|
54
|
-
self.results_mean = []
|
|
55
|
-
self.results_sig = []
|
|
83
|
+
self.final_percentage_diff = None
|
|
56
84
|
|
|
57
|
-
def
|
|
85
|
+
def _load_measured_data(self):
|
|
58
86
|
"""
|
|
59
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
site_model : SiteModel
|
|
69
|
-
The site model.
|
|
91
|
+
astropy.table.Column
|
|
92
|
+
Column containing PSF diameter values in mm.
|
|
70
93
|
"""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
150
|
+
def _evaluate(self, mirror_idx, measured, rnda):
|
|
117
151
|
"""
|
|
118
|
-
|
|
152
|
+
Evaluate the objective function for a mirror.
|
|
119
153
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _rnda_bounds():
|
|
174
|
+
"""
|
|
175
|
+
Get allowed bounds for RNDA parameters.
|
|
132
176
|
|
|
133
|
-
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
list of tuple of float
|
|
180
|
+
[(min1, max1), (min2, max2), (min3, max3)]
|
|
134
181
|
"""
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
|
176
|
-
rnda
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
281
|
+
Update a single RNDA parameter using finite-difference gradient descent.
|
|
206
282
|
|
|
207
283
|
Parameters
|
|
208
284
|
----------
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
sig_d80: float
|
|
219
|
-
Standard deviation of D80 in cm.
|
|
295
|
+
float
|
|
296
|
+
Updated RNDA parameter value.
|
|
220
297
|
"""
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
+
Optimize all mirrors in parallel using process pool.
|
|
265
325
|
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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)
|