gammasimtools 0.20.0__py3-none-any.whl → 0.21.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.20.0.dist-info → gammasimtools-0.21.0.dist-info}/METADATA +1 -1
- {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/RECORD +24 -23
- {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/entry_points.txt +1 -1
- simtools/_version.py +2 -2
- simtools/applications/db_generate_compound_indexes.py +1 -1
- simtools/applications/derive_psf_parameters.py +58 -39
- simtools/applications/generate_corsika_histograms.py +7 -184
- simtools/applications/maintain_simulation_model_add_production.py +105 -0
- simtools/applications/plot_simtel_events.py +2 -228
- simtools/applications/print_version.py +8 -7
- simtools/corsika/corsika_histograms.py +81 -0
- simtools/db/db_handler.py +45 -11
- simtools/db/db_model_upload.py +40 -14
- simtools/model/model_repository.py +118 -63
- simtools/ray_tracing/psf_parameter_optimisation.py +999 -565
- simtools/simtel/simtel_config_writer.py +1 -1
- simtools/simulator.py +1 -4
- simtools/version.py +89 -0
- simtools/{corsika/corsika_histograms_visualize.py → visualization/plot_corsika_histograms.py} +109 -0
- simtools/visualization/plot_psf.py +673 -0
- simtools/visualization/plot_simtel_events.py +284 -87
- simtools/applications/maintain_simulation_model_add_production_table.py +0 -71
- {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/WHEEL +0 -0
- {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.0.dist-info}/licenses/LICENSE +0 -0
- {gammasimtools-0.20.0.dist-info → gammasimtools-0.21.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,87 @@ 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
|
|
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
|
|
23
|
+
from simtools.visualization import plot_psf
|
|
26
24
|
|
|
27
25
|
logger = logging.getLogger(__name__)
|
|
28
26
|
|
|
29
27
|
# Constants
|
|
30
|
-
|
|
28
|
+
RADIUS = "Radius"
|
|
31
29
|
CUMULATIVE_PSF = "Cumulative PSF"
|
|
32
|
-
|
|
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
|
|
30
|
+
KS_STATISTIC_NAME = "KS statistic"
|
|
39
31
|
|
|
40
32
|
|
|
41
|
-
def
|
|
42
|
-
"""
|
|
43
|
-
|
|
33
|
+
def _create_log_header_and_format_value(title, tel_model, additional_info=None, value=None):
|
|
34
|
+
"""Create log header and format parameter values."""
|
|
35
|
+
if value is not None: # Format value mode
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return "[" + ", ".join([f"{v:.6f}" for v in value]) + "]"
|
|
38
|
+
if isinstance(value, int | float):
|
|
39
|
+
return f"{value:.6f}"
|
|
40
|
+
return str(value)
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
42
|
+
# Create header mode
|
|
43
|
+
header_lines = [f"# {title}", f"# Telescope: {tel_model.name}"]
|
|
44
|
+
if additional_info:
|
|
45
|
+
for key, val in additional_info.items():
|
|
46
|
+
header_lines.append(f"# {key}: {val}")
|
|
47
|
+
header_lines.extend(["#" + "=" * 65, ""])
|
|
48
|
+
return "\n".join(header_lines) + "\n"
|
|
63
49
|
|
|
64
50
|
|
|
65
51
|
def calculate_rmsd(data, sim):
|
|
66
|
-
"""Calculate
|
|
52
|
+
"""Calculate RMSD between measured and simulated cumulative PSF curves."""
|
|
67
53
|
return np.sqrt(np.mean((data - sim) ** 2))
|
|
68
54
|
|
|
69
55
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
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)
|
|
56
|
+
def calculate_ks_statistic(data, sim):
|
|
57
|
+
"""Calculate the KS statistic between measured and simulated cumulative PSF curves."""
|
|
58
|
+
return stats.ks_2samp(data, sim)
|
|
106
59
|
|
|
107
60
|
|
|
108
61
|
def get_previous_values(tel_model):
|
|
109
62
|
"""
|
|
110
|
-
Retrieve
|
|
63
|
+
Retrieve current PSF parameter values from the telescope model.
|
|
111
64
|
|
|
112
65
|
Parameters
|
|
113
66
|
----------
|
|
114
67
|
tel_model : TelescopeModel
|
|
115
|
-
Telescope model object.
|
|
68
|
+
Telescope model object containing parameter configurations.
|
|
116
69
|
|
|
117
70
|
Returns
|
|
118
71
|
-------
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
72
|
+
dict
|
|
73
|
+
Dictionary containing current values of PSF optimization parameters:
|
|
74
|
+
- 'mirror_reflection_random_angle': Random reflection angle parameters
|
|
75
|
+
- 'mirror_align_random_horizontal': Horizontal alignment parameters
|
|
76
|
+
- 'mirror_align_random_vertical': Vertical alignment parameters
|
|
163
77
|
"""
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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)
|
|
78
|
+
return {
|
|
79
|
+
"mirror_reflection_random_angle": tel_model.get_parameter_value(
|
|
80
|
+
"mirror_reflection_random_angle"
|
|
81
|
+
),
|
|
82
|
+
"mirror_align_random_horizontal": tel_model.get_parameter_value(
|
|
83
|
+
"mirror_align_random_horizontal"
|
|
84
|
+
),
|
|
85
|
+
"mirror_align_random_vertical": tel_model.get_parameter_value(
|
|
86
|
+
"mirror_align_random_vertical"
|
|
87
|
+
),
|
|
88
|
+
}
|
|
190
89
|
|
|
191
90
|
|
|
192
91
|
def _run_ray_tracing_simulation(tel_model, site_model, args_dict, pars):
|
|
193
|
-
"""
|
|
194
|
-
|
|
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:
|
|
92
|
+
"""Run a ray tracing simulation with the given telescope parameters."""
|
|
93
|
+
if pars is None:
|
|
215
94
|
raise ValueError("No best parameters found")
|
|
216
95
|
|
|
96
|
+
tel_model.change_multiple_parameters(**pars)
|
|
217
97
|
ray = RayTracing(
|
|
218
98
|
telescope_model=tel_model,
|
|
219
99
|
site_model=site_model,
|
|
@@ -225,568 +105,1122 @@ def _run_ray_tracing_simulation(tel_model, site_model, args_dict, pars):
|
|
|
225
105
|
ray.simulate(test=args_dict.get("test", False), force=True)
|
|
226
106
|
ray.analyze(force=True, use_rx=False)
|
|
227
107
|
im = ray.images()[0]
|
|
228
|
-
|
|
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()
|
|
108
|
+
return im.get_psf(), im
|
|
303
109
|
|
|
304
110
|
|
|
305
111
|
def run_psf_simulation(
|
|
306
112
|
tel_model,
|
|
307
|
-
|
|
113
|
+
site,
|
|
308
114
|
args_dict,
|
|
309
115
|
pars,
|
|
310
116
|
data_to_plot,
|
|
311
117
|
radius,
|
|
312
118
|
pdf_pages=None,
|
|
313
119
|
is_best=False,
|
|
314
|
-
|
|
120
|
+
use_ks_statistic=False,
|
|
315
121
|
):
|
|
316
122
|
"""
|
|
317
|
-
Run
|
|
123
|
+
Run PSF simulation for given parameters and calculate optimization metric.
|
|
318
124
|
|
|
319
125
|
Parameters
|
|
320
126
|
----------
|
|
321
127
|
tel_model : TelescopeModel
|
|
322
|
-
Telescope model object.
|
|
323
|
-
|
|
324
|
-
Site model object.
|
|
128
|
+
Telescope model object to be configured with the test parameters.
|
|
129
|
+
site : Site
|
|
130
|
+
Site model object with environmental conditions.
|
|
325
131
|
args_dict : dict
|
|
326
|
-
Dictionary containing
|
|
132
|
+
Dictionary containing simulation configuration arguments.
|
|
327
133
|
pars : dict
|
|
328
|
-
|
|
134
|
+
Dictionary of parameter values to test in the simulation.
|
|
329
135
|
data_to_plot : dict
|
|
330
|
-
|
|
136
|
+
Dictionary containing measured PSF data under "measured" key.
|
|
331
137
|
radius : array-like
|
|
332
|
-
Radius
|
|
138
|
+
Radius values in cm for PSF evaluation.
|
|
333
139
|
pdf_pages : PdfPages, optional
|
|
334
|
-
PDF pages object for
|
|
140
|
+
PDF pages object for saving plots (default: None).
|
|
335
141
|
is_best : bool, optional
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
If True,
|
|
142
|
+
Flag indicating if this is the best parameter set (default: False).
|
|
143
|
+
use_ks_statistic : bool, optional
|
|
144
|
+
If True, use KS statistic as metric; if False, use RMSD (default: False).
|
|
339
145
|
|
|
340
146
|
Returns
|
|
341
147
|
-------
|
|
342
|
-
tuple
|
|
343
|
-
|
|
344
|
-
|
|
148
|
+
tuple of (float, float, float or None, array)
|
|
149
|
+
- d80: D80 diameter of the simulated PSF in cm
|
|
150
|
+
- metric: RMSD or KS statistic value
|
|
151
|
+
- p_value: p-value from KS test (None if using RMSD)
|
|
152
|
+
- simulated_data: Structured array with simulated cumulative PSF data
|
|
345
153
|
"""
|
|
346
|
-
d80, im = _run_ray_tracing_simulation(tel_model,
|
|
154
|
+
d80, im = _run_ray_tracing_simulation(tel_model, site, args_dict, pars)
|
|
347
155
|
|
|
348
156
|
if radius is None:
|
|
349
157
|
raise ValueError("Radius data is not available.")
|
|
350
158
|
|
|
351
159
|
simulated_data = im.get_cumulative_data(radius * u.cm)
|
|
352
|
-
|
|
160
|
+
|
|
161
|
+
if use_ks_statistic:
|
|
162
|
+
ks_statistic, p_value = calculate_ks_statistic(
|
|
163
|
+
data_to_plot["measured"][CUMULATIVE_PSF], simulated_data[CUMULATIVE_PSF]
|
|
164
|
+
)
|
|
165
|
+
metric = ks_statistic
|
|
166
|
+
else:
|
|
167
|
+
metric = calculate_rmsd(
|
|
168
|
+
data_to_plot["measured"][CUMULATIVE_PSF], simulated_data[CUMULATIVE_PSF]
|
|
169
|
+
)
|
|
170
|
+
p_value = None
|
|
353
171
|
|
|
354
172
|
# Handle plotting if requested
|
|
355
173
|
if pdf_pages is not None and args_dict.get("plot_all", False):
|
|
356
174
|
data_to_plot["simulated"] = simulated_data
|
|
357
|
-
|
|
175
|
+
plot_psf.create_psf_parameter_plot(
|
|
176
|
+
data_to_plot,
|
|
177
|
+
pars,
|
|
178
|
+
d80,
|
|
179
|
+
metric,
|
|
180
|
+
is_best,
|
|
181
|
+
pdf_pages,
|
|
182
|
+
p_value=p_value,
|
|
183
|
+
use_ks_statistic=use_ks_statistic,
|
|
184
|
+
)
|
|
358
185
|
del data_to_plot["simulated"]
|
|
359
186
|
|
|
360
|
-
return
|
|
187
|
+
return d80, metric, p_value, simulated_data
|
|
361
188
|
|
|
362
189
|
|
|
363
190
|
def load_and_process_data(args_dict):
|
|
364
191
|
"""
|
|
365
|
-
Load and process
|
|
192
|
+
Load and process PSF measurement data from ECSV file.
|
|
193
|
+
|
|
194
|
+
Parameters
|
|
195
|
+
----------
|
|
196
|
+
args_dict : dict
|
|
197
|
+
Dictionary containing command-line arguments with 'data' and 'model_path' keys.
|
|
366
198
|
|
|
367
199
|
Returns
|
|
368
200
|
-------
|
|
369
|
-
|
|
370
|
-
|
|
201
|
+
tuple of (OrderedDict, array)
|
|
202
|
+
- data_dict: OrderedDict with "measured" key containing structured array
|
|
203
|
+
of radius and cumulative PSF data
|
|
204
|
+
- radius: Array of radius values in cm
|
|
205
|
+
|
|
206
|
+
Raises
|
|
207
|
+
------
|
|
208
|
+
FileNotFoundError
|
|
209
|
+
If no data file is specified in args_dict.
|
|
371
210
|
"""
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
211
|
+
if args_dict["data"] is None:
|
|
212
|
+
raise FileNotFoundError("No data file specified for PSF optimization.")
|
|
213
|
+
|
|
214
|
+
data_file = gen.find_file(args_dict["data"], args_dict["model_path"])
|
|
215
|
+
table = Table.read(data_file, format="ascii.ecsv")
|
|
216
|
+
|
|
217
|
+
radius_column = next((col for col in table.colnames if "radius" in col.lower()), None)
|
|
218
|
+
integral_psf_column = next((col for col in table.colnames if "integral" in col.lower()), None)
|
|
379
219
|
|
|
220
|
+
# Create structured array with converted data
|
|
221
|
+
d_type = {"names": (RADIUS, CUMULATIVE_PSF), "formats": ("f8", "f8")}
|
|
222
|
+
data = np.zeros(len(table), dtype=d_type)
|
|
380
223
|
|
|
381
|
-
|
|
224
|
+
data[RADIUS] = table[radius_column].to(u.cm).value
|
|
225
|
+
data[CUMULATIVE_PSF] = table[integral_psf_column]
|
|
226
|
+
data[CUMULATIVE_PSF] /= np.max(np.abs(data[CUMULATIVE_PSF])) # Normalize to max = 1.0
|
|
227
|
+
|
|
228
|
+
return OrderedDict([("measured", data)]), data[RADIUS]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def write_tested_parameters_to_file(results, best_pars, best_d80, output_dir, tel_model):
|
|
382
232
|
"""
|
|
383
|
-
|
|
233
|
+
Write optimization results and tested parameters to a log file.
|
|
384
234
|
|
|
385
235
|
Parameters
|
|
386
236
|
----------
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
Whether this is the best parameter set
|
|
399
|
-
pdf_pages : PdfPages
|
|
400
|
-
PDF pages object to save the plot
|
|
401
|
-
"""
|
|
402
|
-
original_simulated = data_to_plot.get("simulated")
|
|
403
|
-
data_to_plot["simulated"] = simulated_data
|
|
237
|
+
results : list
|
|
238
|
+
List of tuples containing (parameters, ks_statistic, p_value, d80, simulated_data)
|
|
239
|
+
for each tested parameter set.
|
|
240
|
+
best_pars : dict
|
|
241
|
+
Dictionary containing the best parameter values found.
|
|
242
|
+
best_d80 : float
|
|
243
|
+
D80 diameter in cm for the best parameter set.
|
|
244
|
+
output_dir : Path
|
|
245
|
+
Directory where the log file will be written.
|
|
246
|
+
tel_model : TelescopeModel
|
|
247
|
+
Telescope model object for naming the output file.
|
|
404
248
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
f
|
|
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
|
-
)
|
|
249
|
+
Returns
|
|
250
|
+
-------
|
|
251
|
+
Path
|
|
252
|
+
Path to the created log file.
|
|
253
|
+
"""
|
|
254
|
+
param_file = output_dir.joinpath(f"psf_optimization_{tel_model.name}.log")
|
|
255
|
+
with open(param_file, "w", encoding="utf-8") as f:
|
|
256
|
+
header = _create_log_header_and_format_value(
|
|
257
|
+
"PSF Parameter Optimization Log",
|
|
258
|
+
tel_model,
|
|
259
|
+
{"Total parameter sets tested": len(results)},
|
|
260
|
+
)
|
|
261
|
+
f.write(header)
|
|
430
262
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
)
|
|
263
|
+
f.write("PARAMETER TESTING RESULTS:\n")
|
|
264
|
+
for i, (pars, ks_statistic, p_value, d80, _) in enumerate(results):
|
|
265
|
+
status = "BEST" if pars is best_pars else "TESTED"
|
|
266
|
+
f.write(
|
|
267
|
+
f"[{status}] Set {i + 1:03d}: KS_stat={ks_statistic:.5f}, "
|
|
268
|
+
f"p_value={p_value:.5f}, D80={d80:.5f} cm\n"
|
|
269
|
+
)
|
|
270
|
+
for par, value in pars.items():
|
|
271
|
+
f.write(f" {par}: {value}\n")
|
|
272
|
+
f.write("\n")
|
|
447
273
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
color="red",
|
|
456
|
-
)
|
|
274
|
+
f.write("OPTIMIZATION SUMMARY:\n")
|
|
275
|
+
f.write(f"Best KS statistic: {min(result[1] for result in results):.5f}\n")
|
|
276
|
+
f.write(f"Best D80: {best_d80:.5f} cm\n")
|
|
277
|
+
f.write("\nOPTIMIZED PARAMETERS:\n")
|
|
278
|
+
for par, value in best_pars.items():
|
|
279
|
+
f.write(f"{par}: {value}\n")
|
|
280
|
+
return param_file
|
|
457
281
|
|
|
458
|
-
pdf_pages.savefig(fig, bbox_inches="tight")
|
|
459
|
-
plt.clf()
|
|
460
282
|
|
|
461
|
-
|
|
462
|
-
|
|
283
|
+
def _add_units_to_psf_parameters(best_pars):
|
|
284
|
+
"""Add astropy units to PSF parameters based on their schemas."""
|
|
285
|
+
psf_pars_with_units = {}
|
|
286
|
+
for param_name, param_values in best_pars.items():
|
|
287
|
+
if param_name == "mirror_reflection_random_angle":
|
|
288
|
+
psf_pars_with_units[param_name] = [
|
|
289
|
+
param_values[0] * u.deg,
|
|
290
|
+
param_values[1] * u.dimensionless_unscaled,
|
|
291
|
+
param_values[2] * u.deg,
|
|
292
|
+
]
|
|
293
|
+
elif param_name in ["mirror_align_random_horizontal", "mirror_align_random_vertical"]:
|
|
294
|
+
psf_pars_with_units[param_name] = [
|
|
295
|
+
param_values[0] * u.deg,
|
|
296
|
+
param_values[1] * u.deg,
|
|
297
|
+
param_values[2] * u.dimensionless_unscaled,
|
|
298
|
+
param_values[3] * u.dimensionless_unscaled,
|
|
299
|
+
]
|
|
300
|
+
else:
|
|
301
|
+
psf_pars_with_units[param_name] = param_values
|
|
302
|
+
return psf_pars_with_units
|
|
463
303
|
|
|
464
304
|
|
|
465
|
-
def
|
|
305
|
+
def export_psf_parameters(best_pars, telescope, parameter_version, output_dir):
|
|
466
306
|
"""
|
|
467
|
-
|
|
307
|
+
Export optimized PSF parameters as simulation model parameter files.
|
|
468
308
|
|
|
469
309
|
Parameters
|
|
470
310
|
----------
|
|
471
|
-
results : list
|
|
472
|
-
List of (pars, rmsd, d80, simulated_data) tuples
|
|
473
311
|
best_pars : dict
|
|
474
|
-
|
|
312
|
+
Dictionary containing the optimized parameter values.
|
|
313
|
+
telescope : str
|
|
314
|
+
Telescope name for the parameter files.
|
|
315
|
+
parameter_version : str
|
|
316
|
+
Version string for the parameter files.
|
|
317
|
+
output_dir : Path
|
|
318
|
+
Base directory for parameter file output.
|
|
319
|
+
|
|
320
|
+
Notes
|
|
321
|
+
-----
|
|
322
|
+
Creates individual JSON files for each optimized parameter with
|
|
323
|
+
units. Files are saved in the format:
|
|
324
|
+
{output_dir}/{telescope}/{parameter_name}-{parameter_version}.json
|
|
325
|
+
|
|
326
|
+
Raises
|
|
327
|
+
------
|
|
328
|
+
ValueError, KeyError, OSError
|
|
329
|
+
If parameter export fails due to invalid values, missing keys, or file I/O errors.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
psf_pars_with_units = _add_units_to_psf_parameters(best_pars)
|
|
333
|
+
parameter_output_path = output_dir.parent / telescope
|
|
334
|
+
for parameter_name, parameter_value in psf_pars_with_units.items():
|
|
335
|
+
writer.ModelDataWriter.dump_model_parameter(
|
|
336
|
+
parameter_name=parameter_name,
|
|
337
|
+
value=parameter_value,
|
|
338
|
+
instrument=telescope,
|
|
339
|
+
parameter_version=parameter_version,
|
|
340
|
+
output_file=f"{parameter_name}-{parameter_version}.json",
|
|
341
|
+
output_path=parameter_output_path,
|
|
342
|
+
use_plain_output_path=True,
|
|
343
|
+
)
|
|
344
|
+
logger.info(f"simulation model parameter files exported to {output_dir}")
|
|
345
|
+
|
|
346
|
+
except (ValueError, KeyError, OSError) as e:
|
|
347
|
+
logger.error(f"Error exporting simulation parameters: {e}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _calculate_param_gradient(
|
|
351
|
+
tel_model,
|
|
352
|
+
site_model,
|
|
353
|
+
args_dict,
|
|
354
|
+
current_params,
|
|
355
|
+
data_to_plot,
|
|
356
|
+
radius,
|
|
357
|
+
current_rmsd,
|
|
358
|
+
param_name,
|
|
359
|
+
param_values,
|
|
360
|
+
epsilon,
|
|
361
|
+
use_ks_statistic,
|
|
362
|
+
):
|
|
363
|
+
"""
|
|
364
|
+
Calculate numerical gradient for a single parameter using finite differences.
|
|
365
|
+
|
|
366
|
+
The gradient is calculated using forward finite differences:
|
|
367
|
+
gradient = (f(x + epsilon) - f(x)) / epsilon
|
|
368
|
+
|
|
369
|
+
Parameters
|
|
370
|
+
----------
|
|
371
|
+
tel_model : TelescopeModel
|
|
372
|
+
The telescope model object containing the current parameter configuration.
|
|
373
|
+
site_model : SiteModel
|
|
374
|
+
The site model object with environmental conditions.
|
|
375
|
+
args_dict : dict
|
|
376
|
+
Dictionary containing simulation arguments and configuration options.
|
|
377
|
+
current_params : dict
|
|
378
|
+
Dictionary of current parameter values for all optimization parameters.
|
|
475
379
|
data_to_plot : dict
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
380
|
+
Dictionary containing measured PSF data with "measured" key.
|
|
381
|
+
radius : array-like
|
|
382
|
+
Radius values in cm for PSF evaluation.
|
|
383
|
+
current_rmsd : float
|
|
384
|
+
Current RMSD at the current parameter configuration.
|
|
385
|
+
param_name : str
|
|
386
|
+
Name of the parameter for which to calculate the gradient.
|
|
387
|
+
param_values : float or list
|
|
388
|
+
Current value(s) of the parameter. Can be a single value or list of values.
|
|
389
|
+
epsilon : float
|
|
390
|
+
Small perturbation value for finite difference calculation.
|
|
391
|
+
use_ks_statistic : bool
|
|
392
|
+
If True, calculate gradient with respect to KS statistic; if False, use RMSD.
|
|
393
|
+
|
|
394
|
+
Returns
|
|
395
|
+
-------
|
|
396
|
+
float or list
|
|
397
|
+
Gradient value(s) for the parameter. Returns a single float if param_values
|
|
398
|
+
is a single value, or a list of gradients if param_values is a list.
|
|
399
|
+
|
|
400
|
+
If a simulation fails during gradient calculation, a gradient of 0.0 is assigned
|
|
401
|
+
for that component to ensure the optimization can continue.
|
|
479
402
|
"""
|
|
480
|
-
|
|
403
|
+
param_gradients = []
|
|
404
|
+
values_list = param_values if isinstance(param_values, list) else [param_values]
|
|
481
405
|
|
|
482
|
-
for i,
|
|
483
|
-
|
|
484
|
-
|
|
406
|
+
for i, value in enumerate(values_list):
|
|
407
|
+
perturbed_params = {
|
|
408
|
+
k: v.copy() if isinstance(v, list) else v for k, v in current_params.items()
|
|
409
|
+
}
|
|
485
410
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
411
|
+
if isinstance(param_values, list):
|
|
412
|
+
perturbed_params[param_name][i] = value + epsilon
|
|
413
|
+
else:
|
|
414
|
+
perturbed_params[param_name] = value + epsilon
|
|
489
415
|
|
|
416
|
+
try:
|
|
417
|
+
_, perturbed_rmsd, _, _ = run_psf_simulation(
|
|
418
|
+
tel_model,
|
|
419
|
+
site_model,
|
|
420
|
+
args_dict,
|
|
421
|
+
perturbed_params,
|
|
422
|
+
data_to_plot,
|
|
423
|
+
radius,
|
|
424
|
+
pdf_pages=None,
|
|
425
|
+
is_best=False,
|
|
426
|
+
use_ks_statistic=use_ks_statistic,
|
|
427
|
+
)
|
|
428
|
+
param_gradients.append((perturbed_rmsd - current_rmsd) / epsilon)
|
|
429
|
+
except (ValueError, RuntimeError):
|
|
430
|
+
param_gradients.append(0.0)
|
|
490
431
|
|
|
491
|
-
|
|
492
|
-
|
|
432
|
+
return param_gradients[0] if not isinstance(param_values, list) else param_gradients
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def calculate_gradient(
|
|
436
|
+
tel_model,
|
|
437
|
+
site_model,
|
|
438
|
+
args_dict,
|
|
439
|
+
current_params,
|
|
440
|
+
data_to_plot,
|
|
441
|
+
radius,
|
|
442
|
+
current_rmsd,
|
|
443
|
+
epsilon=0.0005,
|
|
444
|
+
use_ks_statistic=False,
|
|
493
445
|
):
|
|
494
446
|
"""
|
|
495
|
-
|
|
447
|
+
Calculate numerical gradients for all optimization parameters.
|
|
448
|
+
|
|
449
|
+
Parameters
|
|
450
|
+
----------
|
|
451
|
+
tel_model : TelescopeModel
|
|
452
|
+
Telescope model object for simulations.
|
|
453
|
+
site_model : SiteModel
|
|
454
|
+
Site model object with environmental conditions.
|
|
455
|
+
args_dict : dict
|
|
456
|
+
Dictionary containing simulation configuration arguments.
|
|
457
|
+
current_params : dict
|
|
458
|
+
Dictionary of current parameter values for all optimization parameters.
|
|
459
|
+
data_to_plot : dict
|
|
460
|
+
Dictionary containing measured PSF data.
|
|
461
|
+
radius : array-like
|
|
462
|
+
Radius values in cm for PSF evaluation.
|
|
463
|
+
current_rmsd : float
|
|
464
|
+
Current RMSD or KS statistic value.
|
|
465
|
+
epsilon : float, optional
|
|
466
|
+
Perturbation value for finite difference calculation (default: 0.0005).
|
|
467
|
+
use_ks_statistic : bool, optional
|
|
468
|
+
If True, calculate gradients for KS statistic; if False, use RMSD (default: False).
|
|
496
469
|
|
|
497
|
-
|
|
498
|
-
|
|
470
|
+
Returns
|
|
471
|
+
-------
|
|
472
|
+
dict
|
|
473
|
+
Dictionary mapping parameter names to their gradient values.
|
|
474
|
+
For parameters with multiple components, gradients are returned as lists.
|
|
499
475
|
"""
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
476
|
+
gradients = {}
|
|
477
|
+
|
|
478
|
+
for param_name, param_values in current_params.items():
|
|
479
|
+
gradients[param_name] = _calculate_param_gradient(
|
|
480
|
+
tel_model,
|
|
481
|
+
site_model,
|
|
482
|
+
args_dict,
|
|
483
|
+
current_params,
|
|
484
|
+
data_to_plot,
|
|
485
|
+
radius,
|
|
486
|
+
current_rmsd,
|
|
487
|
+
param_name,
|
|
488
|
+
param_values,
|
|
489
|
+
epsilon,
|
|
490
|
+
use_ks_statistic,
|
|
491
|
+
)
|
|
504
492
|
|
|
505
|
-
|
|
493
|
+
return gradients
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def apply_gradient_step(current_params, gradients, learning_rate):
|
|
497
|
+
"""
|
|
498
|
+
Apply gradient descent step to update parameters.
|
|
506
499
|
|
|
507
|
-
|
|
500
|
+
Parameters
|
|
501
|
+
----------
|
|
502
|
+
current_params : dict
|
|
503
|
+
Dictionary of current parameter values.
|
|
504
|
+
gradients : dict
|
|
505
|
+
Dictionary of gradient values for each parameter.
|
|
506
|
+
learning_rate : float
|
|
507
|
+
Step size for the gradient descent update.
|
|
508
|
+
|
|
509
|
+
Returns
|
|
510
|
+
-------
|
|
511
|
+
dict
|
|
512
|
+
Dictionary of updated parameter values after applying the gradient step.
|
|
513
|
+
"""
|
|
514
|
+
new_params = {}
|
|
515
|
+
for param_name, param_values in current_params.items():
|
|
516
|
+
param_gradients = gradients[param_name]
|
|
517
|
+
|
|
518
|
+
if isinstance(param_values, list):
|
|
519
|
+
new_params[param_name] = [
|
|
520
|
+
value - learning_rate * gradient
|
|
521
|
+
for value, gradient in zip(param_values, param_gradients)
|
|
522
|
+
]
|
|
523
|
+
else:
|
|
524
|
+
new_params[param_name] = param_values - learning_rate * param_gradients
|
|
525
|
+
|
|
526
|
+
return new_params
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _perform_gradient_step_with_retries(
|
|
530
|
+
tel_model,
|
|
531
|
+
site_model,
|
|
532
|
+
args_dict,
|
|
533
|
+
current_params,
|
|
534
|
+
current_metric,
|
|
535
|
+
data_to_plot,
|
|
536
|
+
radius,
|
|
537
|
+
learning_rate,
|
|
538
|
+
max_retries=3,
|
|
539
|
+
):
|
|
540
|
+
"""
|
|
541
|
+
Attempt gradient descent step with adaptive learning rate reduction on rejection.
|
|
542
|
+
|
|
543
|
+
The learning rate reduction strategy follows these rules:
|
|
544
|
+
- If step is rejected: learning_rate *= 0.7
|
|
545
|
+
- If attempt number < number of max retries then try again
|
|
546
|
+
- If learning_rate drops below 1e-5: reset to 0.001
|
|
547
|
+
- If all retries fail: returns None values with step_accepted=False
|
|
548
|
+
|
|
549
|
+
This adaptive approach helps navigate local minima and ensures robust convergence
|
|
550
|
+
by automatically adjusting the step size based on optimization progress.
|
|
551
|
+
|
|
552
|
+
Parameters
|
|
553
|
+
----------
|
|
554
|
+
tel_model : TelescopeModel
|
|
555
|
+
Telescope model object containing the current parameter configuration.
|
|
556
|
+
site_model : SiteModel
|
|
557
|
+
Site model object with environmental conditions for ray tracing simulations.
|
|
558
|
+
args_dict : dict
|
|
559
|
+
Dictionary containing simulation configuration arguments and settings.
|
|
560
|
+
current_params : dict
|
|
561
|
+
Dictionary of current parameter values for all optimization parameters.
|
|
562
|
+
current_metric : float
|
|
563
|
+
Current optimization metric value (RMSD or KS statistic) to improve upon.
|
|
564
|
+
data_to_plot : dict
|
|
565
|
+
Dictionary containing measured PSF data under "measured" key for comparison.
|
|
566
|
+
radius : array-like
|
|
567
|
+
Radius values in cm for PSF evaluation and comparison.
|
|
568
|
+
learning_rate : float
|
|
569
|
+
Initial learning rate for the gradient descent step.
|
|
570
|
+
max_retries : int, optional
|
|
571
|
+
Maximum number of attempts with learning rate reduction (default: 3).
|
|
572
|
+
|
|
573
|
+
Returns
|
|
574
|
+
-------
|
|
575
|
+
tuple of (dict, float, float, float or None, array, bool, float)
|
|
576
|
+
- new_params: Updated parameter dictionary if step accepted, None if rejected
|
|
577
|
+
- new_d80: D80 diameter in cm for new parameters, None if step rejected
|
|
578
|
+
- new_metric: New optimization metric value, None if step rejected
|
|
579
|
+
- new_p_value: p-value from KS test if applicable, None otherwise
|
|
580
|
+
- new_simulated_data: Simulated PSF data array, None if step rejected
|
|
581
|
+
- step_accepted: Boolean indicating if any step was accepted
|
|
582
|
+
- final_learning_rate: Learning rate after potential reductions
|
|
583
|
+
|
|
584
|
+
"""
|
|
585
|
+
current_lr = learning_rate
|
|
586
|
+
|
|
587
|
+
for attempt in range(max_retries):
|
|
508
588
|
try:
|
|
509
|
-
|
|
510
|
-
d80, rmsd, simulated_data = run_psf_simulation(
|
|
589
|
+
gradients = calculate_gradient(
|
|
511
590
|
tel_model,
|
|
512
591
|
site_model,
|
|
513
592
|
args_dict,
|
|
514
|
-
|
|
593
|
+
current_params,
|
|
594
|
+
data_to_plot,
|
|
595
|
+
radius,
|
|
596
|
+
current_metric,
|
|
597
|
+
use_ks_statistic=False,
|
|
598
|
+
)
|
|
599
|
+
new_params = apply_gradient_step(current_params, gradients, current_lr)
|
|
600
|
+
|
|
601
|
+
new_d80, new_metric, new_p_value, new_simulated_data = run_psf_simulation(
|
|
602
|
+
tel_model,
|
|
603
|
+
site_model,
|
|
604
|
+
args_dict,
|
|
605
|
+
new_params,
|
|
515
606
|
data_to_plot,
|
|
516
607
|
radius,
|
|
517
|
-
return_simulated_data=True,
|
|
518
608
|
pdf_pages=None,
|
|
609
|
+
is_best=False,
|
|
610
|
+
use_ks_statistic=False,
|
|
519
611
|
)
|
|
520
|
-
|
|
521
|
-
|
|
612
|
+
|
|
613
|
+
if new_metric < current_metric:
|
|
614
|
+
return (
|
|
615
|
+
new_params,
|
|
616
|
+
new_d80,
|
|
617
|
+
new_metric,
|
|
618
|
+
new_p_value,
|
|
619
|
+
new_simulated_data,
|
|
620
|
+
True,
|
|
621
|
+
current_lr,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
logger.info(
|
|
625
|
+
f"Step rejected (RMSD {current_metric:.6f} -> {new_metric:.6f}), "
|
|
626
|
+
f"reducing learning rate {current_lr:.6f} -> {current_lr * 0.7:.6f}"
|
|
627
|
+
)
|
|
628
|
+
current_lr *= 0.7
|
|
629
|
+
|
|
630
|
+
if current_lr < 1e-5:
|
|
631
|
+
current_lr = 0.001
|
|
632
|
+
|
|
633
|
+
except (ValueError, RuntimeError, KeyError) as e:
|
|
634
|
+
logger.warning(f"Simulation failed on attempt {attempt + 1}: {e}")
|
|
522
635
|
continue
|
|
523
636
|
|
|
524
|
-
|
|
525
|
-
if rmsd < best_rmsd:
|
|
526
|
-
best_rmsd = rmsd
|
|
527
|
-
best_pars = pars
|
|
528
|
-
best_d80 = d80
|
|
637
|
+
return None, None, None, None, None, False, current_lr
|
|
529
638
|
|
|
530
|
-
logger.info(f"Best RMSD found: {best_rmsd:.5f}")
|
|
531
639
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
640
|
+
def _create_step_plot(
|
|
641
|
+
pdf_pages,
|
|
642
|
+
args_dict,
|
|
643
|
+
data_to_plot,
|
|
644
|
+
current_params,
|
|
645
|
+
new_d80,
|
|
646
|
+
new_metric,
|
|
647
|
+
new_p_value,
|
|
648
|
+
new_simulated_data,
|
|
649
|
+
):
|
|
650
|
+
"""Create plot for an accepted gradient step."""
|
|
651
|
+
if pdf_pages is None or not args_dict.get("plot_all", False) or new_simulated_data is None:
|
|
652
|
+
return
|
|
653
|
+
|
|
654
|
+
data_to_plot["simulated"] = new_simulated_data
|
|
655
|
+
plot_psf.create_psf_parameter_plot(
|
|
656
|
+
data_to_plot,
|
|
657
|
+
current_params,
|
|
658
|
+
new_d80,
|
|
659
|
+
new_metric,
|
|
660
|
+
False,
|
|
661
|
+
pdf_pages,
|
|
662
|
+
p_value=new_p_value,
|
|
663
|
+
use_ks_statistic=False,
|
|
664
|
+
)
|
|
665
|
+
del data_to_plot["simulated"]
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _create_final_plot(
|
|
669
|
+
pdf_pages, tel_model, site_model, args_dict, best_params, data_to_plot, radius, best_d80
|
|
670
|
+
):
|
|
671
|
+
"""Create final plot for best parameters."""
|
|
672
|
+
if pdf_pages is None or best_params is None:
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
logger.info("Creating final plot for best parameters with both RMSD and KS statistic...")
|
|
676
|
+
_, best_ks_stat, best_p_value, best_simulated_data = run_psf_simulation(
|
|
677
|
+
tel_model,
|
|
678
|
+
site_model,
|
|
679
|
+
args_dict,
|
|
680
|
+
best_params,
|
|
681
|
+
data_to_plot,
|
|
682
|
+
radius,
|
|
683
|
+
pdf_pages=None,
|
|
684
|
+
is_best=False,
|
|
685
|
+
use_ks_statistic=True,
|
|
686
|
+
)
|
|
687
|
+
best_rmsd = calculate_rmsd(
|
|
688
|
+
data_to_plot["measured"][CUMULATIVE_PSF], best_simulated_data[CUMULATIVE_PSF]
|
|
689
|
+
)
|
|
535
690
|
|
|
536
|
-
|
|
691
|
+
data_to_plot["simulated"] = best_simulated_data
|
|
692
|
+
plot_psf.create_psf_parameter_plot(
|
|
693
|
+
data_to_plot,
|
|
694
|
+
best_params,
|
|
695
|
+
best_d80,
|
|
696
|
+
best_rmsd,
|
|
697
|
+
True,
|
|
698
|
+
pdf_pages,
|
|
699
|
+
p_value=best_p_value,
|
|
700
|
+
use_ks_statistic=False,
|
|
701
|
+
second_metric=best_ks_stat,
|
|
702
|
+
)
|
|
703
|
+
del data_to_plot["simulated"]
|
|
704
|
+
pdf_pages.close()
|
|
705
|
+
logger.info("Cumulative PSF plots saved")
|
|
537
706
|
|
|
538
707
|
|
|
539
|
-
def
|
|
708
|
+
def run_gradient_descent_optimization(
|
|
709
|
+
tel_model,
|
|
710
|
+
site_model,
|
|
711
|
+
args_dict,
|
|
712
|
+
data_to_plot,
|
|
713
|
+
radius,
|
|
714
|
+
rmsd_threshold,
|
|
715
|
+
learning_rate,
|
|
716
|
+
output_dir,
|
|
717
|
+
):
|
|
540
718
|
"""
|
|
541
|
-
|
|
719
|
+
Run gradient descent optimization to minimize PSF fitting metric.
|
|
542
720
|
|
|
543
721
|
Parameters
|
|
544
722
|
----------
|
|
545
723
|
tel_model : TelescopeModel
|
|
546
|
-
Telescope model object.
|
|
724
|
+
Telescope model object to be optimized.
|
|
547
725
|
site_model : SiteModel
|
|
548
|
-
Site model object.
|
|
726
|
+
Site model object with environmental conditions.
|
|
549
727
|
args_dict : dict
|
|
550
|
-
Dictionary containing
|
|
551
|
-
|
|
552
|
-
|
|
728
|
+
Dictionary containing simulation configuration arguments.
|
|
729
|
+
data_to_plot : dict
|
|
730
|
+
Dictionary containing measured PSF data under "measured" key.
|
|
731
|
+
radius : array-like
|
|
732
|
+
Radius values in cm for PSF evaluation.
|
|
733
|
+
rmsd_threshold : float
|
|
734
|
+
Convergence threshold for RMSD improvement.
|
|
735
|
+
learning_rate : float
|
|
736
|
+
Initial learning rate for gradient descent steps.
|
|
553
737
|
output_dir : Path
|
|
554
|
-
|
|
738
|
+
Directory for saving optimization plots and results.
|
|
739
|
+
|
|
740
|
+
Returns
|
|
741
|
+
-------
|
|
742
|
+
tuple of (dict, float, list)
|
|
743
|
+
- best_params: Dictionary of optimized parameter values
|
|
744
|
+
- best_d80: D80 diameter in cm for the best parameters
|
|
745
|
+
- results: List of (params, metric, p_value, d80, simulated_data) for each iteration
|
|
746
|
+
|
|
747
|
+
Returns None values if optimization fails or no measurement data is provided.
|
|
555
748
|
"""
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
749
|
+
if data_to_plot is None or radius is None:
|
|
750
|
+
logger.error("No PSF measurement data provided. Cannot run optimization.")
|
|
751
|
+
return None, None, []
|
|
752
|
+
|
|
753
|
+
current_params = get_previous_values(tel_model)
|
|
754
|
+
pdf_pages = plot_psf.setup_pdf_plotting(args_dict, output_dir, tel_model.name)
|
|
755
|
+
results = []
|
|
756
|
+
|
|
757
|
+
# Evaluate initial parameters
|
|
758
|
+
current_d80, current_metric, current_p_value, simulated_data = run_psf_simulation(
|
|
759
|
+
tel_model,
|
|
760
|
+
site_model,
|
|
761
|
+
args_dict,
|
|
762
|
+
current_params,
|
|
763
|
+
data_to_plot,
|
|
764
|
+
radius,
|
|
765
|
+
pdf_pages=pdf_pages if args_dict.get("plot_all", False) else None,
|
|
766
|
+
is_best=False,
|
|
767
|
+
use_ks_statistic=False,
|
|
568
768
|
)
|
|
569
769
|
|
|
570
|
-
|
|
571
|
-
|
|
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,
|
|
770
|
+
results.append(
|
|
771
|
+
(current_params.copy(), current_metric, current_p_value, current_d80, simulated_data)
|
|
577
772
|
)
|
|
773
|
+
best_metric, best_params, best_d80 = current_metric, current_params.copy(), current_d80
|
|
578
774
|
|
|
579
|
-
logger.info(f"
|
|
580
|
-
|
|
581
|
-
|
|
775
|
+
logger.info(f"Initial RMSD: {current_metric:.6f}, D80: {current_d80:.6f} cm")
|
|
776
|
+
|
|
777
|
+
iteration = 0
|
|
778
|
+
max_total_iterations = 100
|
|
779
|
+
|
|
780
|
+
while iteration < max_total_iterations:
|
|
781
|
+
if current_metric <= rmsd_threshold:
|
|
782
|
+
logger.info(
|
|
783
|
+
f"Optimization converged: RMSD {current_metric:.6f} <= "
|
|
784
|
+
f"threshold {rmsd_threshold:.6f}"
|
|
785
|
+
)
|
|
786
|
+
break
|
|
582
787
|
|
|
583
|
-
|
|
584
|
-
|
|
788
|
+
iteration += 1
|
|
789
|
+
logger.info(f"Gradient descent iteration {iteration}")
|
|
585
790
|
|
|
586
|
-
|
|
791
|
+
step_result = _perform_gradient_step_with_retries(
|
|
792
|
+
tel_model,
|
|
793
|
+
site_model,
|
|
794
|
+
args_dict,
|
|
795
|
+
current_params,
|
|
796
|
+
current_metric,
|
|
797
|
+
data_to_plot,
|
|
798
|
+
radius,
|
|
799
|
+
learning_rate,
|
|
800
|
+
)
|
|
801
|
+
(
|
|
802
|
+
new_params,
|
|
803
|
+
new_d80,
|
|
804
|
+
new_metric,
|
|
805
|
+
new_p_value,
|
|
806
|
+
new_simulated_data,
|
|
807
|
+
step_accepted,
|
|
808
|
+
learning_rate,
|
|
809
|
+
) = step_result
|
|
810
|
+
|
|
811
|
+
if not step_accepted or new_params is None:
|
|
812
|
+
learning_rate *= 2.0
|
|
813
|
+
logger.info(f"No step accepted, increasing learning rate to {learning_rate:.6f}")
|
|
814
|
+
continue
|
|
587
815
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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"
|
|
816
|
+
# Step was accepted - update state
|
|
817
|
+
current_params, current_metric, current_d80 = new_params, new_metric, new_d80
|
|
818
|
+
results.append(
|
|
819
|
+
(current_params.copy(), current_metric, None, current_d80, new_simulated_data)
|
|
596
820
|
)
|
|
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
821
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
822
|
+
if current_metric < best_metric:
|
|
823
|
+
best_metric, best_params, best_d80 = current_metric, current_params.copy(), current_d80
|
|
824
|
+
|
|
825
|
+
_create_step_plot(
|
|
826
|
+
pdf_pages,
|
|
827
|
+
args_dict,
|
|
828
|
+
data_to_plot,
|
|
829
|
+
current_params,
|
|
830
|
+
new_d80,
|
|
831
|
+
new_metric,
|
|
832
|
+
new_p_value,
|
|
833
|
+
new_simulated_data,
|
|
834
|
+
)
|
|
835
|
+
logger.info(f" Accepted step: improved to {new_metric:.6f}")
|
|
607
836
|
|
|
608
|
-
|
|
837
|
+
_create_final_plot(
|
|
838
|
+
pdf_pages, tel_model, site_model, args_dict, best_params, data_to_plot, radius, best_d80
|
|
839
|
+
)
|
|
840
|
+
return best_params, best_d80, results
|
|
609
841
|
|
|
610
842
|
|
|
611
|
-
def
|
|
843
|
+
def _write_log_interpretation(f, use_ks_statistic):
|
|
844
|
+
"""Write interpretation section for the log file."""
|
|
845
|
+
if use_ks_statistic:
|
|
846
|
+
f.write(
|
|
847
|
+
"P-VALUE INTERPRETATION:\n p > 0.05: Distributions are statistically similar "
|
|
848
|
+
"(good fit)\n"
|
|
849
|
+
" p < 0.05: Distributions are significantly different (poor fit)\n"
|
|
850
|
+
" p < 0.01: Very significant difference (very poor fit)\n\n"
|
|
851
|
+
)
|
|
852
|
+
else:
|
|
853
|
+
f.write(
|
|
854
|
+
"RMSD INTERPRETATION:\n Lower RMSD values indicate better agreement between "
|
|
855
|
+
"measured and simulated PSF curves\n\n"
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _write_iteration_entry(
|
|
860
|
+
f, iteration, pars, metric, p_value, d80, use_ks_statistic, metric_name, total_iterations
|
|
861
|
+
):
|
|
862
|
+
"""Write a single iteration entry."""
|
|
863
|
+
status = "FINAL" if iteration == total_iterations - 1 else f"ITER-{iteration:02d}"
|
|
864
|
+
|
|
865
|
+
if use_ks_statistic and p_value is not None:
|
|
866
|
+
significance = plot_psf.get_significance_label(p_value)
|
|
867
|
+
f.write(
|
|
868
|
+
f"[{status}] Iteration {iteration}: KS_stat={metric:.6f}, "
|
|
869
|
+
f"p_value={p_value:.6f} ({significance}), D80={d80:.6f} cm\n"
|
|
870
|
+
)
|
|
871
|
+
else:
|
|
872
|
+
f.write(f"[{status}] Iteration {iteration}: {metric_name}={metric:.6f}, D80={d80:.6f} cm\n")
|
|
873
|
+
|
|
874
|
+
for par, value in pars.items():
|
|
875
|
+
f.write(f" {par}: {_create_log_header_and_format_value(None, None, None, value)}\n")
|
|
876
|
+
f.write("\n")
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _write_optimization_summary(f, gd_results, best_pars, best_d80, metric_name):
|
|
880
|
+
"""Write optimization summary section."""
|
|
881
|
+
f.write("OPTIMIZATION SUMMARY:\n")
|
|
882
|
+
best_metric_from_results = min(metric for _, metric, _, _, _ in gd_results)
|
|
883
|
+
f.write(f"Best {metric_name.lower()}: {best_metric_from_results:.6f}\n")
|
|
884
|
+
f.write(f"Best D80: {best_d80:.6f} cm\n" if best_d80 is not None else "Best D80: N/A\n")
|
|
885
|
+
f.write(f"Total iterations: {len(gd_results)}\n\nFINAL OPTIMIZED PARAMETERS:\n")
|
|
886
|
+
for par, value in best_pars.items():
|
|
887
|
+
f.write(f"{par}: {_create_log_header_and_format_value(None, None, None, value)}\n")
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def write_gradient_descent_log(
|
|
891
|
+
gd_results, best_pars, best_d80, output_dir, tel_model, use_ks_statistic=False
|
|
892
|
+
):
|
|
612
893
|
"""
|
|
613
|
-
Write
|
|
894
|
+
Write gradient descent optimization progression to a log file.
|
|
614
895
|
|
|
615
896
|
Parameters
|
|
616
897
|
----------
|
|
617
|
-
|
|
618
|
-
List of (
|
|
898
|
+
gd_results : list
|
|
899
|
+
List of tuples containing (params, metric, p_value, d80, simulated_data)
|
|
900
|
+
for each optimization iteration.
|
|
619
901
|
best_pars : dict
|
|
620
|
-
|
|
902
|
+
Dictionary containing the best parameter values found.
|
|
621
903
|
best_d80 : float
|
|
622
|
-
|
|
904
|
+
D80 diameter in cm for the best parameter set.
|
|
623
905
|
output_dir : Path
|
|
624
|
-
|
|
906
|
+
Directory where the log file will be written.
|
|
625
907
|
tel_model : TelescopeModel
|
|
626
|
-
Telescope model object for
|
|
908
|
+
Telescope model object for naming the output file.
|
|
909
|
+
use_ks_statistic : bool, optional
|
|
910
|
+
If True, log KS statistic values; if False, log RMSD values (default: False).
|
|
911
|
+
|
|
912
|
+
Returns
|
|
913
|
+
-------
|
|
914
|
+
Path
|
|
915
|
+
Path to the created log file.
|
|
627
916
|
"""
|
|
628
|
-
|
|
917
|
+
metric_name = "KS Statistic" if use_ks_statistic else "RMSD"
|
|
918
|
+
file_suffix = "ks" if use_ks_statistic else "rmsd"
|
|
919
|
+
param_file = output_dir.joinpath(f"psf_gradient_descent_{file_suffix}_{tel_model.name}.log")
|
|
920
|
+
|
|
629
921
|
with open(param_file, "w", encoding="utf-8") as f:
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
922
|
+
header = _create_log_header_and_format_value(
|
|
923
|
+
f"PSF Parameter Optimization - Gradient Descent Progression ({metric_name})",
|
|
924
|
+
tel_model,
|
|
925
|
+
{"Total iterations": len(gd_results)},
|
|
926
|
+
)
|
|
927
|
+
f.write(header)
|
|
634
928
|
|
|
635
|
-
f.write(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
929
|
+
f.write(
|
|
930
|
+
"GRADIENT DESCENT PROGRESSION:\n(Each entry shows the parameters chosen "
|
|
931
|
+
"at each iteration)\n\n"
|
|
932
|
+
)
|
|
933
|
+
_write_log_interpretation(f, use_ks_statistic)
|
|
934
|
+
|
|
935
|
+
for iteration, (pars, metric, p_value, d80, _) in enumerate(gd_results):
|
|
936
|
+
_write_iteration_entry(
|
|
937
|
+
f,
|
|
938
|
+
iteration,
|
|
939
|
+
pars,
|
|
940
|
+
metric,
|
|
941
|
+
p_value,
|
|
942
|
+
d80,
|
|
943
|
+
use_ks_statistic,
|
|
944
|
+
metric_name,
|
|
945
|
+
len(gd_results),
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
_write_optimization_summary(f, gd_results, best_pars, best_d80, metric_name)
|
|
643
949
|
|
|
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
950
|
return param_file
|
|
651
951
|
|
|
652
952
|
|
|
653
|
-
def
|
|
953
|
+
def analyze_monte_carlo_error(
|
|
954
|
+
tel_model, site_model, args_dict, data_to_plot, radius, n_simulations=500
|
|
955
|
+
):
|
|
654
956
|
"""
|
|
655
|
-
|
|
957
|
+
Analyze Monte Carlo uncertainty in PSF optimization metrics.
|
|
958
|
+
|
|
959
|
+
Runs multiple simulations with the same parameters to quantify the
|
|
960
|
+
statistical uncertainty in the optimization metric due to Monte Carlo
|
|
961
|
+
noise in the ray tracing simulations. Returns None values if no
|
|
962
|
+
measurement data is provided or all simulations fail.
|
|
656
963
|
|
|
657
964
|
Parameters
|
|
658
965
|
----------
|
|
659
|
-
|
|
660
|
-
|
|
966
|
+
tel_model : TelescopeModel
|
|
967
|
+
Telescope model object with current parameter configuration.
|
|
968
|
+
site_model : SiteModel
|
|
969
|
+
Site model object with environmental conditions.
|
|
970
|
+
args_dict : dict
|
|
971
|
+
Dictionary containing simulation configuration arguments.
|
|
972
|
+
data_to_plot : dict
|
|
973
|
+
Dictionary containing measured PSF data under "measured" key.
|
|
974
|
+
radius : array-like
|
|
975
|
+
Radius values in cm for PSF evaluation.
|
|
976
|
+
n_simulations : int, optional
|
|
977
|
+
Number of Monte Carlo simulations to run (default: 500).
|
|
661
978
|
|
|
662
979
|
Returns
|
|
663
980
|
-------
|
|
664
|
-
|
|
665
|
-
|
|
981
|
+
tuple of (float, float, list, float, float, list, float, float, list)
|
|
982
|
+
- mean_metric: Mean RMSD or KS statistic value
|
|
983
|
+
- std_metric: Standard deviation of metric values
|
|
984
|
+
- metric_values: List of all metric values from simulations
|
|
985
|
+
- mean_p_value: Mean p-value (None if using RMSD)
|
|
986
|
+
- std_p_value: Standard deviation of p-values (None if using RMSD)
|
|
987
|
+
- p_values: List of all p-values from simulations
|
|
988
|
+
- mean_d80: Mean D80 diameter in cm
|
|
989
|
+
- std_d80: Standard deviation of D80 values
|
|
990
|
+
- d80_values: List of all D80 values from simulations
|
|
666
991
|
"""
|
|
667
|
-
|
|
992
|
+
if data_to_plot is None or radius is None:
|
|
993
|
+
logger.error("No PSF measurement data provided. Cannot analyze Monte Carlo error.")
|
|
994
|
+
return None, None, []
|
|
668
995
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
|
996
|
+
initial_params = get_previous_values(tel_model)
|
|
997
|
+
for param_name, param_values in initial_params.items():
|
|
998
|
+
logger.info(f" {param_name}: {param_values}")
|
|
685
999
|
|
|
686
|
-
|
|
1000
|
+
use_ks_statistic = args_dict.get("ks_statistic", False)
|
|
1001
|
+
metric_values, p_values, d80_values = [], [], []
|
|
687
1002
|
|
|
1003
|
+
for i in range(n_simulations):
|
|
1004
|
+
try:
|
|
1005
|
+
d80, metric, p_value, _ = run_psf_simulation(
|
|
1006
|
+
tel_model,
|
|
1007
|
+
site_model,
|
|
1008
|
+
args_dict,
|
|
1009
|
+
initial_params,
|
|
1010
|
+
data_to_plot,
|
|
1011
|
+
radius,
|
|
1012
|
+
use_ks_statistic=use_ks_statistic,
|
|
1013
|
+
)
|
|
1014
|
+
metric_values.append(metric)
|
|
1015
|
+
d80_values.append(d80)
|
|
1016
|
+
p_values.append(p_value)
|
|
1017
|
+
except (ValueError, RuntimeError) as e:
|
|
1018
|
+
logger.warning(f"WARNING: Simulation {i + 1} failed: {e}")
|
|
688
1019
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1020
|
+
if not metric_values:
|
|
1021
|
+
logger.error("All Monte Carlo simulations failed.")
|
|
1022
|
+
return None, None, [], None, None, []
|
|
692
1023
|
|
|
693
|
-
|
|
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}")
|
|
1024
|
+
mean_metric, std_metric = np.mean(metric_values), np.std(metric_values, ddof=1)
|
|
1025
|
+
mean_d80, std_d80 = np.mean(d80_values), np.std(d80_values, ddof=1)
|
|
723
1026
|
|
|
1027
|
+
if use_ks_statistic:
|
|
1028
|
+
valid_p_values = [p for p in p_values if p is not None]
|
|
1029
|
+
mean_p_value = np.mean(valid_p_values) if valid_p_values else None
|
|
1030
|
+
std_p_value = np.std(valid_p_values, ddof=1) if valid_p_values else None
|
|
1031
|
+
else:
|
|
1032
|
+
mean_p_value = std_p_value = None
|
|
1033
|
+
|
|
1034
|
+
return (
|
|
1035
|
+
mean_metric,
|
|
1036
|
+
std_metric,
|
|
1037
|
+
metric_values,
|
|
1038
|
+
mean_p_value,
|
|
1039
|
+
std_p_value,
|
|
1040
|
+
p_values,
|
|
1041
|
+
mean_d80,
|
|
1042
|
+
std_d80,
|
|
1043
|
+
d80_values,
|
|
1044
|
+
)
|
|
724
1045
|
|
|
725
|
-
def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
|
|
726
|
-
"""
|
|
727
|
-
Run the complete PSF parameter optimization workflow.
|
|
728
1046
|
|
|
729
|
-
|
|
1047
|
+
def write_monte_carlo_analysis(mc_results, output_dir, tel_model, use_ks_statistic=False):
|
|
1048
|
+
"""
|
|
1049
|
+
Write Monte Carlo uncertainty analysis results to a log file.
|
|
730
1050
|
|
|
731
1051
|
Parameters
|
|
732
1052
|
----------
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
site_model : SiteModel
|
|
736
|
-
Site model object
|
|
737
|
-
args_dict : dict
|
|
738
|
-
Dictionary containing parsed command-line arguments
|
|
1053
|
+
mc_results : tuple
|
|
1054
|
+
Tuple of Monte Carlo analysis results from analyze_monte_carlo_error().
|
|
739
1055
|
output_dir : Path
|
|
740
|
-
|
|
1056
|
+
Directory where the log file will be written.
|
|
1057
|
+
tel_model : TelescopeModel
|
|
1058
|
+
Telescope model object for naming the output file.
|
|
1059
|
+
use_ks_statistic : bool, optional
|
|
1060
|
+
If True, analyze KS statistic results; if False, analyze RMSD results (default: False).
|
|
741
1061
|
|
|
742
1062
|
Returns
|
|
743
1063
|
-------
|
|
744
|
-
|
|
745
|
-
|
|
1064
|
+
Path
|
|
1065
|
+
Path to the created log file.
|
|
746
1066
|
"""
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1067
|
+
(
|
|
1068
|
+
mean_metric,
|
|
1069
|
+
std_metric,
|
|
1070
|
+
metric_values,
|
|
1071
|
+
mean_p_value,
|
|
1072
|
+
std_p_value,
|
|
1073
|
+
p_values,
|
|
1074
|
+
mean_d80,
|
|
1075
|
+
std_d80,
|
|
1076
|
+
d80_values,
|
|
1077
|
+
) = mc_results
|
|
1078
|
+
|
|
1079
|
+
metric_name = "KS Statistic" if use_ks_statistic else "RMSD"
|
|
1080
|
+
file_suffix = "ks" if use_ks_statistic else "rmsd"
|
|
1081
|
+
mc_file = output_dir.joinpath(f"monte_carlo_{file_suffix}_analysis_{tel_model.name}.log")
|
|
1082
|
+
|
|
1083
|
+
with open(mc_file, "w", encoding="utf-8") as f:
|
|
1084
|
+
header = _create_log_header_and_format_value(
|
|
1085
|
+
f"Monte Carlo {metric_name} Error Analysis",
|
|
1086
|
+
tel_model,
|
|
1087
|
+
{"Number of simulations": len(metric_values)},
|
|
1088
|
+
)
|
|
1089
|
+
f.write(header)
|
|
750
1090
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1091
|
+
f.write(
|
|
1092
|
+
f"MONTE CARLO SIMULATION RESULTS:\nNumber of successful simulations: "
|
|
1093
|
+
f"{len(metric_values)}\n\n"
|
|
1094
|
+
)
|
|
1095
|
+
f.write(f"{metric_name.upper()} STATISTICS:\n")
|
|
1096
|
+
f.write(
|
|
1097
|
+
f"Mean {metric_name.lower()}: {mean_metric:.6f}\n"
|
|
1098
|
+
f"Standard deviation: {std_metric:.6f}\n"
|
|
1099
|
+
f"Minimum {metric_name.lower()}: {min(metric_values):.6f}\n"
|
|
1100
|
+
f"Maximum {metric_name.lower()}: {max(metric_values):.6f}\n"
|
|
1101
|
+
f"Relative error: {(std_metric / mean_metric) * 100:.2f}%\n\n"
|
|
1102
|
+
)
|
|
755
1103
|
|
|
1104
|
+
if use_ks_statistic and mean_p_value is not None:
|
|
1105
|
+
valid_p_values = [p for p in p_values if p is not None]
|
|
1106
|
+
f.write(
|
|
1107
|
+
f"P-VALUE STATISTICS:\nMean p-value: {mean_p_value:.6f}\n"
|
|
1108
|
+
f"Standard deviation: {std_p_value:.6f}\n"
|
|
1109
|
+
f"Minimum p-value: {min(valid_p_values):.6f}\n"
|
|
1110
|
+
f"Maximum p-value: {max(valid_p_values):.6f}\n"
|
|
1111
|
+
f"Relative error: {(std_p_value / mean_p_value) * 100:.2f}%\n"
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
good_fits = sum(1 for p in valid_p_values if p > 0.05)
|
|
1115
|
+
fair_fits = sum(1 for p in valid_p_values if 0.01 < p <= 0.05)
|
|
1116
|
+
poor_fits = sum(1 for p in valid_p_values if p <= 0.01)
|
|
1117
|
+
f.write(
|
|
1118
|
+
f"Good fits (p > 0.05): {good_fits}/{len(valid_p_values)} "
|
|
1119
|
+
f"({100 * good_fits / len(valid_p_values):.1f}%)\n"
|
|
1120
|
+
f"Fair fits (0.01 < p <= 0.05): {fair_fits}/{len(valid_p_values)} "
|
|
1121
|
+
f"({100 * fair_fits / len(valid_p_values):.1f}%)\n"
|
|
1122
|
+
f"Poor fits (p <= 0.01): {poor_fits}/{len(valid_p_values)} "
|
|
1123
|
+
f"({100 * poor_fits / len(valid_p_values):.1f}%)\n\n"
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
f.write(
|
|
1127
|
+
f"D80 STATISTICS:\nMean D80: {mean_d80:.6f} cm\n"
|
|
1128
|
+
f"Standard deviation: {std_d80:.6f} cm\n"
|
|
1129
|
+
f"Minimum D80: {min(d80_values):.6f} cm\n"
|
|
1130
|
+
f"Maximum D80: {max(d80_values):.6f} cm\n"
|
|
1131
|
+
f"Relative error: {(std_d80 / mean_d80) * 100:.2f}%\n\n"
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
f.write("INDIVIDUAL SIMULATION RESULTS:\n")
|
|
1135
|
+
for i, (metric_val, p_value, d80) in enumerate(zip(metric_values, p_values, d80_values)):
|
|
1136
|
+
if use_ks_statistic and p_value is not None:
|
|
1137
|
+
if p_value > 0.05:
|
|
1138
|
+
significance = "GOOD"
|
|
1139
|
+
elif p_value > 0.01:
|
|
1140
|
+
significance = "FAIR"
|
|
1141
|
+
else:
|
|
1142
|
+
significance = "POOR"
|
|
1143
|
+
f.write(
|
|
1144
|
+
f"Simulation {i + 1:2d}: {metric_name}={metric_val:.6f}, "
|
|
1145
|
+
f"p_value={p_value:.6f} ({significance}), D80={d80:.6f} cm\n"
|
|
1146
|
+
)
|
|
1147
|
+
else:
|
|
1148
|
+
f.write(
|
|
1149
|
+
f"Simulation {i + 1:2d}: {metric_name}={metric_val:.6f}, D80={d80:.6f} cm\n"
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
return mc_file
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _handle_monte_carlo_analysis(
|
|
1156
|
+
tel_model, site_model, args_dict, data_to_plot, radius, output_dir, use_ks_statistic
|
|
1157
|
+
):
|
|
1158
|
+
"""Handle Monte Carlo analysis if requested."""
|
|
1159
|
+
if not args_dict.get("monte_carlo_analysis", False):
|
|
1160
|
+
return False
|
|
1161
|
+
|
|
1162
|
+
mc_results = analyze_monte_carlo_error(tel_model, site_model, args_dict, data_to_plot, radius)
|
|
1163
|
+
if mc_results[0] is not None:
|
|
1164
|
+
mc_file = write_monte_carlo_analysis(mc_results, output_dir, tel_model, use_ks_statistic)
|
|
1165
|
+
logger.info(f"Monte Carlo analysis results written to {mc_file}")
|
|
1166
|
+
mc_plot_file = output_dir.joinpath(f"monte_carlo_uncertainty_{tel_model.name}.pdf")
|
|
1167
|
+
plot_psf.create_monte_carlo_uncertainty_plot(mc_results, mc_plot_file, use_ks_statistic)
|
|
1168
|
+
return True
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def run_psf_optimization_workflow(tel_model, site_model, args_dict, output_dir):
|
|
1172
|
+
"""Run the complete PSF parameter optimization workflow using gradient descent."""
|
|
756
1173
|
data_to_plot, radius = load_and_process_data(args_dict)
|
|
1174
|
+
use_ks_statistic = args_dict.get("ks_statistic", False)
|
|
757
1175
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1176
|
+
if _handle_monte_carlo_analysis(
|
|
1177
|
+
tel_model, site_model, args_dict, data_to_plot, radius, output_dir, use_ks_statistic
|
|
1178
|
+
):
|
|
1179
|
+
return
|
|
762
1180
|
|
|
763
|
-
#
|
|
764
|
-
|
|
765
|
-
|
|
1181
|
+
# Run gradient descent optimization
|
|
1182
|
+
threshold = args_dict.get("rmsd_threshold")
|
|
1183
|
+
learning_rate = args_dict.get("learning_rate")
|
|
1184
|
+
|
|
1185
|
+
best_pars, best_d80, gd_results = run_gradient_descent_optimization(
|
|
1186
|
+
tel_model,
|
|
1187
|
+
site_model,
|
|
1188
|
+
args_dict,
|
|
1189
|
+
data_to_plot,
|
|
1190
|
+
radius,
|
|
1191
|
+
rmsd_threshold=threshold,
|
|
1192
|
+
learning_rate=learning_rate,
|
|
1193
|
+
output_dir=output_dir,
|
|
766
1194
|
)
|
|
767
1195
|
|
|
768
|
-
|
|
769
|
-
|
|
1196
|
+
# Check if optimization was successful
|
|
1197
|
+
if not gd_results or best_pars is None:
|
|
1198
|
+
logger.error("Gradient descent optimization failed. No valid results found.")
|
|
1199
|
+
if radius is None:
|
|
1200
|
+
logger.error(
|
|
1201
|
+
"Possible cause: No PSF measurement data provided. "
|
|
1202
|
+
"Use --data argument to provide PSF data."
|
|
1203
|
+
)
|
|
1204
|
+
return
|
|
1205
|
+
|
|
1206
|
+
plot_psf.create_optimization_plots(args_dict, gd_results, tel_model, data_to_plot, output_dir)
|
|
770
1207
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1208
|
+
convergence_plot_file = output_dir.joinpath(
|
|
1209
|
+
f"gradient_descent_convergence_{tel_model.name}.png"
|
|
1210
|
+
)
|
|
1211
|
+
plot_psf.create_gradient_descent_convergence_plot(
|
|
1212
|
+
gd_results, threshold, convergence_plot_file, use_ks_statistic
|
|
774
1213
|
)
|
|
775
|
-
print(f"\nParameter results written to {param_file}")
|
|
776
1214
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1215
|
+
param_file = write_gradient_descent_log(
|
|
1216
|
+
gd_results, best_pars, best_d80, output_dir, tel_model, use_ks_statistic
|
|
1217
|
+
)
|
|
1218
|
+
logger.info(f"\nGradient descent progression written to {param_file}")
|
|
780
1219
|
|
|
781
|
-
|
|
782
|
-
for par, value in best_pars.items():
|
|
783
|
-
print(f"{par} = {value}")
|
|
1220
|
+
plot_psf.create_d80_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, output_dir)
|
|
784
1221
|
|
|
785
|
-
# Export best parameters as simulation model parameter files (if flag is provided)
|
|
786
1222
|
if args_dict.get("write_psf_parameters", False):
|
|
1223
|
+
logger.info("Exporting best parameters as model files...")
|
|
787
1224
|
export_psf_parameters(
|
|
788
|
-
best_pars,
|
|
789
|
-
tel_model,
|
|
790
|
-
args_dict.get("parameter_version", "0.0.0"),
|
|
791
|
-
output_dir.parent,
|
|
1225
|
+
best_pars, args_dict.get("telescope"), args_dict.get("parameter_version"), output_dir
|
|
792
1226
|
)
|