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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/METADATA +5 -1
  2. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/RECORD +70 -66
  3. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/WHEEL +1 -1
  4. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/entry_points.txt +1 -1
  5. simtools/_version.py +2 -2
  6. simtools/applications/convert_geo_coordinates_of_array_elements.py +2 -1
  7. simtools/applications/db_get_array_layouts_from_db.py +1 -1
  8. simtools/applications/{calculate_incident_angles.py → derive_incident_angle.py} +16 -16
  9. simtools/applications/derive_mirror_rnda.py +111 -177
  10. simtools/applications/generate_corsika_histograms.py +38 -1
  11. simtools/applications/generate_regular_arrays.py +73 -36
  12. simtools/applications/simulate_flasher.py +3 -13
  13. simtools/applications/simulate_illuminator.py +2 -10
  14. simtools/applications/simulate_pedestals.py +1 -1
  15. simtools/applications/simulate_prod.py +8 -7
  16. simtools/applications/submit_data_from_external.py +2 -1
  17. simtools/applications/validate_camera_efficiency.py +28 -27
  18. simtools/applications/validate_cumulative_psf.py +1 -3
  19. simtools/applications/validate_optics.py +2 -1
  20. simtools/atmosphere.py +83 -0
  21. simtools/camera/camera_efficiency.py +171 -48
  22. simtools/camera/single_photon_electron_spectrum.py +6 -6
  23. simtools/configuration/commandline_parser.py +47 -9
  24. simtools/constants.py +5 -0
  25. simtools/corsika/corsika_config.py +88 -185
  26. simtools/corsika/corsika_histograms.py +246 -69
  27. simtools/data_model/model_data_writer.py +46 -49
  28. simtools/data_model/schema.py +2 -0
  29. simtools/db/db_handler.py +4 -2
  30. simtools/db/mongo_db.py +2 -2
  31. simtools/io/ascii_handler.py +51 -3
  32. simtools/io/io_handler.py +23 -12
  33. simtools/job_execution/job_manager.py +154 -79
  34. simtools/job_execution/process_pool.py +137 -0
  35. simtools/layout/array_layout.py +0 -1
  36. simtools/layout/array_layout_utils.py +143 -21
  37. simtools/model/array_model.py +22 -50
  38. simtools/model/calibration_model.py +4 -4
  39. simtools/model/model_parameter.py +123 -73
  40. simtools/model/model_utils.py +40 -1
  41. simtools/model/site_model.py +4 -4
  42. simtools/model/telescope_model.py +4 -5
  43. simtools/ray_tracing/incident_angles.py +87 -6
  44. simtools/ray_tracing/mirror_panel_psf.py +337 -217
  45. simtools/ray_tracing/psf_analysis.py +57 -42
  46. simtools/ray_tracing/psf_parameter_optimisation.py +3 -2
  47. simtools/ray_tracing/ray_tracing.py +37 -10
  48. simtools/runners/corsika_runner.py +52 -191
  49. simtools/runners/corsika_simtel_runner.py +74 -100
  50. simtools/runners/runner_services.py +214 -213
  51. simtools/runners/simtel_runner.py +27 -155
  52. simtools/runners/simtools_runner.py +9 -69
  53. simtools/schemas/application_workflow.metaschema.yml +8 -0
  54. simtools/settings.py +19 -0
  55. simtools/simtel/simtel_config_writer.py +0 -55
  56. simtools/simtel/simtel_seeds.py +184 -0
  57. simtools/simtel/simulator_array.py +115 -103
  58. simtools/simtel/simulator_camera_efficiency.py +66 -42
  59. simtools/simtel/simulator_light_emission.py +110 -123
  60. simtools/simtel/simulator_ray_tracing.py +78 -63
  61. simtools/simulator.py +135 -346
  62. simtools/testing/sim_telarray_metadata.py +13 -11
  63. simtools/testing/validate_output.py +87 -19
  64. simtools/utils/general.py +6 -17
  65. simtools/utils/random.py +36 -0
  66. simtools/visualization/plot_corsika_histograms.py +2 -0
  67. simtools/visualization/plot_incident_angles.py +48 -1
  68. simtools/visualization/plot_psf.py +160 -18
  69. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/licenses/LICENSE +0 -0
  70. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/top_level.txt +0 -0
@@ -6,11 +6,11 @@ import numpy as np
6
6
 
7
7
  from simtools.sim_events.file_info import get_corsika_run_number
8
8
  from simtools.simtel.simtel_config_reader import SimtelConfigReader
9
- from simtools.simtel.simtel_config_writer import sim_telarray_random_seeds
10
9
  from simtools.simtel.simtel_io_metadata import (
11
10
  get_sim_telarray_telescope_id,
12
11
  read_sim_telarray_metadata,
13
12
  )
13
+ from simtools.utils import random
14
14
 
15
15
  _logger = logging.getLogger(__name__)
16
16
 
@@ -30,7 +30,7 @@ def assert_sim_telarray_metadata(file, array_model):
30
30
  _logger.info(f"Found metadata in sim_telarray file for {len(telescope_meta)} telescopes")
31
31
  site_parameter_mismatch = _assert_model_parameters(global_meta, array_model.site_model)
32
32
  sim_telarray_seed_mismatch = _assert_sim_telarray_seed(
33
- global_meta, array_model.sim_telarray_seeds, file
33
+ global_meta, array_model.sim_telarray_seed, file
34
34
  )
35
35
  if sim_telarray_seed_mismatch:
36
36
  site_parameter_mismatch.append(sim_telarray_seed_mismatch)
@@ -101,7 +101,7 @@ def _assert_model_parameters(metadata, model):
101
101
  return invalid_parameter_list
102
102
 
103
103
 
104
- def _assert_sim_telarray_seed(metadata, sim_telarray_seeds, file=None):
104
+ def _assert_sim_telarray_seed(metadata, sim_telarray_seed, file=None):
105
105
  """
106
106
  Assert that sim_telarray seed matches the values in the sim_telarray metadata.
107
107
 
@@ -111,8 +111,8 @@ def _assert_sim_telarray_seed(metadata, sim_telarray_seeds, file=None):
111
111
  ----------
112
112
  metadata: dict
113
113
  Metadata dictionary.
114
- sim_telarray_seeds: dict
115
- Dictionary of sim_telarray seeds.
114
+ sim_telarray_seed: SimtelSeeds
115
+ sim_telarray seed.
116
116
  file : Path
117
117
  Path to the sim_telarray file.
118
118
 
@@ -122,23 +122,25 @@ def _assert_sim_telarray_seed(metadata, sim_telarray_seeds, file=None):
122
122
  Error message if sim_telarray seeds do not match.
123
123
 
124
124
  """
125
- if not sim_telarray_seeds or not metadata:
125
+ if sim_telarray_seed is None:
126
126
  return None
127
127
 
128
128
  if "instrument_seed" in metadata.keys() and "instrument_instances" in metadata.keys():
129
- if str(metadata.get("instrument_seed")) != str(sim_telarray_seeds.get("seed")):
129
+ if str(metadata.get("instrument_seed")) != str(sim_telarray_seed.instrument_seed):
130
130
  return (
131
131
  "Parameter instrument_seed mismatch between sim_telarray file: "
132
- f"{metadata['instrument_seed']}, and model: {sim_telarray_seeds.get('seed')}"
132
+ f"{metadata['instrument_seed']}, and model: {sim_telarray_seed.instrument_seed}"
133
133
  )
134
134
  _logger.info(
135
135
  f"sim_telarray_seed in sim_telarray file: {metadata['instrument_seed']}, "
136
- f"and model: {sim_telarray_seeds.get('seed')}"
136
+ f"and model: {sim_telarray_seed.instrument_seed}"
137
137
  )
138
138
  if file:
139
139
  run_number_modified = get_corsika_run_number(file) - 1
140
- test_seeds = sim_telarray_random_seeds(
141
- int(metadata["instrument_seed"]), int(metadata["instrument_instances"])
140
+ test_seeds = random.seeds(
141
+ n_seeds=int(metadata["instrument_instances"]),
142
+ max_seed=np.iinfo(np.int32).max,
143
+ fixed_seed=int(metadata["instrument_seed"]),
142
144
  )
143
145
  # no +1 as in sim_telarray (as we count from 0)
144
146
  seed_used = run_number_modified % int(metadata["instrument_instances"])
@@ -46,7 +46,7 @@ def validate_application_output(config, from_command_line=None, from_config_file
46
46
  """
47
47
  Validate application output against expected output.
48
48
 
49
- Expected output is defined in configuration file.
49
+ Expected output is defined in the test configuration file.
50
50
  Some tests run only if the model version from the command line
51
51
  equals the model version from the configuration file.
52
52
 
@@ -88,6 +88,8 @@ def _validate_output_files(config, integration_test):
88
88
  if "reference_output_file" in integration_test:
89
89
  _validate_reference_output_file(config, integration_test)
90
90
  if "test_output_files" in integration_test:
91
+ if isinstance(integration_test["test_output_files"], dict):
92
+ integration_test["test_output_files"] = [integration_test["test_output_files"]]
91
93
  _validate_output_path_and_file(config, integration_test["test_output_files"])
92
94
  if "output_file" in integration_test:
93
95
  _validate_output_path_and_file(
@@ -116,11 +118,10 @@ def _test_simtel_cfg_files(config, integration_test, from_command_line, from_con
116
118
 
117
119
  def _validate_reference_output_file(config, integration_test):
118
120
  """Compare with reference output file."""
121
+ test_file = integration_test.get("test_output_file") or config["configuration"]["output_file"]
119
122
  assert compare_files(
120
123
  integration_test["reference_output_file"],
121
- Path(config["configuration"]["output_path"]).joinpath(
122
- config["configuration"]["output_file"]
123
- ),
124
+ Path(config["configuration"]["output_path"]).joinpath(test_file),
124
125
  integration_test.get("tolerance", 1.0e-5),
125
126
  integration_test.get("test_columns", None),
126
127
  )
@@ -189,6 +190,7 @@ def _validate_model_parameter_json_file(config, model_parameter_validation):
189
190
  model_parameter["value"],
190
191
  reference_model_parameter[reference_parameter_name]["value"],
191
192
  model_parameter_validation["tolerance"],
193
+ model_parameter_validation.get("scaling", 1.0),
192
194
  )
193
195
 
194
196
 
@@ -227,12 +229,56 @@ def compare_files(file1, file2, tolerance=1.0e-5, test_columns=None):
227
229
  return False
228
230
 
229
231
 
232
+ def _compare_nested_dicts_with_tolerance(data1, data2, tolerance, is_value_field=False):
233
+ """
234
+ Recursively compare nested dictionaries, applying allclose to "value" fields.
235
+
236
+ Parameters
237
+ ----------
238
+ data1 : dict, list, or scalar
239
+ First data to compare
240
+ data2 : dict, list, or scalar
241
+ Second data to compare
242
+ tolerance : float
243
+ Tolerance for comparing numerical values
244
+ is_value_field : bool
245
+ Whether this data is from a "value" key (applies tolerance to scalars)
246
+
247
+ Returns
248
+ -------
249
+ bool
250
+ True if the data are equal within tolerance
251
+ """
252
+ if isinstance(data1, dict) and isinstance(data2, dict):
253
+ return data1.keys() == data2.keys() and all(
254
+ _compare_nested_dicts_with_tolerance(
255
+ data1[k], data2[k], tolerance, is_value_field=(k == "value")
256
+ )
257
+ for k in data1
258
+ )
259
+
260
+ if isinstance(data1, (list, tuple)) and isinstance(data2, (list, tuple)):
261
+ return len(data1) == len(data2) and all(
262
+ _compare_nested_dicts_with_tolerance(v1, v2, tolerance, is_value_field)
263
+ for v1, v2 in zip(data1, data2)
264
+ )
265
+
266
+ # Apply tolerance if this is a "value" field, otherwise use exact equality
267
+ if is_value_field:
268
+ try:
269
+ return _compare_value_from_parameter_dict(data1, data2, tolerance)
270
+ except (TypeError, ValueError):
271
+ return data1 == data2
272
+ return data1 == data2
273
+
274
+
230
275
  def compare_json_or_yaml_files(file1, file2, tolerance=1.0e-2):
231
276
  """
232
277
  Compare two json or yaml files.
233
278
 
234
279
  Take into account float comparison for sim_telarray string-embedded floats.
235
280
  Allow differences in 'schema_version' field.
281
+ Works recursively for nested dicts with "value" fields on any level.
236
282
 
237
283
  Parameters
238
284
  ----------
@@ -259,24 +305,45 @@ def compare_json_or_yaml_files(file1, file2, tolerance=1.0e-2):
259
305
  if data1 == data2:
260
306
  return True
261
307
 
262
- if data1.keys() != data2.keys():
263
- _logger.error(f"Keys do not match: {data1.keys()} and {data2.keys()}")
264
- return False
265
- _comparison = all(
266
- (
267
- _compare_value_from_parameter_dict(data1[k], data2[k], tolerance)
268
- if k == "value"
269
- else data1[k] == data2[k]
270
- )
271
- for k in data1
308
+ if isinstance(data1, dict) and isinstance(data2, dict):
309
+ if data1.keys() != data2.keys():
310
+ _logger.error(f"Keys do not match: {data1.keys()} and {data2.keys()}")
311
+ return False
312
+
313
+ _comparison = _compare_nested_dicts_with_tolerance(
314
+ data1, data2, tolerance, is_value_field=False
272
315
  )
273
316
  if not _comparison:
274
317
  _logger.error(f"Values do not match: {data1} and {data2} (tolerance: {tolerance})")
275
318
  return _comparison
276
319
 
277
320
 
278
- def _compare_value_from_parameter_dict(data1, data2, tolerance=1.0e-5):
279
- """Compare value fields given in different formats."""
321
+ def _compare_value_from_parameter_dict(data_1, data_2, tolerance=1.0e-5, factor_1=1.0):
322
+ """
323
+ Compare value fields given in different formats.
324
+
325
+ Parameters
326
+ ----------
327
+ data_1 : float, int, str, list, numpy.ndarray
328
+ First value or collection of values to compare. May be a scalar,
329
+ a sequence, a numpy array, or a string representation of a list.
330
+ data_2 : float, int, str, list, numpy.ndarray
331
+ Second value or collection of values to compare, with the same
332
+ allowed formats as ``data_2``.
333
+ tolerance : float, optional
334
+ Relative tolerance used when comparing numerical values via
335
+ ``numpy.allclose``.
336
+ factor1 : float, optional
337
+ Multiplicative factor applied to ``data_1`` before comparison. This
338
+ can be used to account for unit conversions or normalisation
339
+ differences between ``data_1`` and ``data_2``.
340
+
341
+ Returns
342
+ -------
343
+ bool
344
+ True if the two values are considered equal within the given
345
+ tolerance, False otherwise.
346
+ """
280
347
 
281
348
  def _as_list(value):
282
349
  if isinstance(value, str):
@@ -285,12 +352,13 @@ def _compare_value_from_parameter_dict(data1, data2, tolerance=1.0e-5):
285
352
  return value
286
353
  return [value]
287
354
 
288
- _logger.info(f"Comparing values: {data1} and {data2} (tolerance: {tolerance})")
355
+ _logger.info(f"Comparing values: {data_1} and {data_2} (tolerance: {tolerance})")
289
356
 
290
- _as_list_1 = _as_list(data1)
291
- _as_list_2 = _as_list(data2)
357
+ _as_list_1 = _as_list(data_1)
358
+ _as_list_2 = _as_list(data_2)
292
359
  if isinstance(_as_list_1, str):
293
360
  return _as_list_1 == _as_list_2
361
+ _as_list_1 = np.array(_as_list_1) * factor_1
294
362
  return np.allclose(_as_list_1, _as_list_2, rtol=tolerance)
295
363
 
296
364
 
simtools/utils/general.py CHANGED
@@ -582,6 +582,12 @@ def validate_data_type(reference_dtype, value=None, dtype=None, allow_subtypes=T
582
582
  if reference_dtype in ("boolean", "bool"):
583
583
  return _is_valid_boolean_type(dtype, value)
584
584
 
585
+ if reference_dtype == "dict":
586
+ return isinstance(value, dict)
587
+
588
+ if reference_dtype == "list":
589
+ return isinstance(value, list)
590
+
585
591
  return _is_valid_numeric_type(dtype, reference_dtype)
586
592
 
587
593
 
@@ -796,23 +802,6 @@ def find_differences_in_json_objects(obj1, obj2, path=""):
796
802
  return diffs
797
803
 
798
804
 
799
- def clear_default_sim_telarray_cfg_directories(command):
800
- """Prefix the command to clear default sim_telarray configuration directories.
801
-
802
- Parameters
803
- ----------
804
- command: str
805
- Command to be prefixed.
806
-
807
- Returns
808
- -------
809
- str
810
- Prefixed command.
811
-
812
- """
813
- return f"SIM_TELARRAY_CONFIG_PATH='' {command}"
814
-
815
-
816
805
  def get_list_of_files_from_command_line(file_names, suffix_list):
817
806
  """
818
807
  Get a list of files from the command line.
@@ -0,0 +1,36 @@
1
+ """Random numbers utilities."""
2
+
3
+ import secrets
4
+
5
+ import numpy as np
6
+
7
+
8
+ def seeds(n_seeds=1, min_seed=1, max_seed=2_000_000_000, fixed_seed=None):
9
+ """
10
+ Generate independent random seeds.
11
+
12
+ Parameters
13
+ ----------
14
+ n_seeds : int
15
+ Number of seeds to generate.
16
+ min_seed : int
17
+ Lower limit for the seed (inclusive).
18
+ max_seed : int
19
+ Upper limit for the seed (exclusive).
20
+ fixed_seed : int or None
21
+ If provided, use this fixed seed.
22
+
23
+ Returns
24
+ -------
25
+ int or list of int:
26
+ A single seed if n_seeds is 1, otherwise a list of seeds.
27
+ """
28
+ entropy = fixed_seed if fixed_seed is not None else secrets.randbits(128)
29
+ ss = np.random.SeedSequence(entropy)
30
+ rng = np.random.default_rng(ss)
31
+
32
+ seed_list = rng.integers(low=min_seed, high=max_seed, size=n_seeds)
33
+
34
+ if n_seeds == 1:
35
+ return int(seed_list[0])
36
+ return [int(x) for x in seed_list]
@@ -149,6 +149,8 @@ def _plot_1d(hist_list, labels=None):
149
149
  ax.set_xlabel(_get_axis_label(hist["x_axis_title"], hist["x_axis_unit"]))
150
150
  ax.set_ylabel(_get_axis_label(hist["y_axis_title"], hist["y_axis_unit"]))
151
151
  _configure_plot_scales(ax, hist)
152
+ if "y_axis_min" in hist and hist["y_axis_min"] is not None:
153
+ ax.set_ylim(bottom=float(hist["y_axis_min"]))
152
154
  ax.set_title(f"{hist['title']}")
153
155
  ax.legend()
154
156
  ax.grid(True, alpha=0.3)
@@ -351,6 +351,7 @@ def _plot_component_angles(
351
351
  out_path,
352
352
  bin_width_deg,
353
353
  log,
354
+ model_version=None,
354
355
  ):
355
356
  arrays = _gather_angle_arrays(results_by_offset, column, log)
356
357
  if not arrays:
@@ -365,6 +366,17 @@ def _plot_component_angles(
365
366
  ax.set_title(f"Incident angle {title_suffix} vs off-axis angle")
366
367
  ax.grid(True, alpha=0.3)
367
368
  ax.legend()
369
+ if model_version:
370
+ ax.text(
371
+ 0.03,
372
+ 0.97,
373
+ f"Model version: {model_version}",
374
+ transform=ax.transAxes,
375
+ fontsize=8,
376
+ verticalalignment="top",
377
+ horizontalalignment="left",
378
+ bbox={"boxstyle": "round", "facecolor": "white", "alpha": 0.5},
379
+ )
368
380
  plt.tight_layout()
369
381
  plt.savefig(out_path, dpi=300)
370
382
  plt.close(fig)
@@ -378,8 +390,30 @@ def plot_incident_angles(
378
390
  radius_bin_width_m=0.01,
379
391
  debug_plots=False,
380
392
  logger=None,
393
+ model_version=None,
381
394
  ):
382
- """Plot overlaid histograms of focal, primary, secondary angles, and primary hit radius."""
395
+ """Plot overlaid histograms of focal, primary, secondary angles, and primary hit radius.
396
+
397
+ Parameters
398
+ ----------
399
+ results_by_offset : dict
400
+ Mapping from off-axis angle to result tables containing angle and radius columns.
401
+ output_dir : path-like
402
+ Base output directory where the ``plots`` subdirectory will be created.
403
+ label : str
404
+ Label used to distinguish this set of plots in the output filenames.
405
+ bin_width_deg : float, optional
406
+ Bin width in degrees for the angle-of-incidence histograms.
407
+ radius_bin_width_m : float, optional
408
+ Bin width in meters for the primary mirror hit-radius histograms.
409
+ debug_plots : bool, optional
410
+ If True, generate additional diagnostic plots.
411
+ logger : logging.Logger or None, optional
412
+ Logger instance to use for messages. If None, a module-level logger is used.
413
+ model_version : str or None, optional
414
+ Semantic model version identifier to annotate the generated plots. If None,
415
+ no model version text is added to the figures.
416
+ """
383
417
  log = logger or logging.getLogger(__name__)
384
418
  if not results_by_offset:
385
419
  log.warning("No results provided for multi-offset plot")
@@ -402,6 +436,17 @@ def plot_incident_angles(
402
436
  ax.set_title("Incident angle distribution vs off-axis angle")
403
437
  ax.grid(True, alpha=0.3)
404
438
  ax.legend()
439
+ if model_version:
440
+ ax.text(
441
+ 0.03,
442
+ 0.97,
443
+ f"Model version: {model_version}",
444
+ transform=ax.transAxes,
445
+ fontsize=8,
446
+ verticalalignment="top",
447
+ horizontalalignment="left",
448
+ bbox={"boxstyle": "round", "facecolor": "white", "alpha": 0.5},
449
+ )
405
450
  plt.tight_layout()
406
451
  plt.savefig(out_dir / f"incident_angles_multi_{label}.png", dpi=300)
407
452
  plt.close(fig)
@@ -414,6 +459,7 @@ def plot_incident_angles(
414
459
  out_path=out_dir / f"incident_angles_primary_multi_{label}.png",
415
460
  bin_width_deg=bin_width_deg,
416
461
  log=log,
462
+ model_version=model_version,
417
463
  )
418
464
  _plot_component_angles(
419
465
  results_by_offset=results_by_offset,
@@ -422,6 +468,7 @@ def plot_incident_angles(
422
468
  out_path=out_dir / f"incident_angles_secondary_multi_{label}.png",
423
469
  bin_width_deg=bin_width_deg,
424
470
  log=log,
471
+ model_version=model_version,
425
472
  )
426
473
 
427
474
  # Debug plots
@@ -6,6 +6,7 @@ including parameter comparison plots, convergence plots, and PSF diameter vs off
6
6
  """
7
7
 
8
8
  import logging
9
+ from pathlib import Path
9
10
 
10
11
  import astropy.units as u
11
12
  import matplotlib.pyplot as plt
@@ -141,22 +142,27 @@ def _create_base_plot_figure(data_to_plot, simulated_data=None):
141
142
 
142
143
 
143
144
  def _build_parameter_title(pars, is_best):
144
- """Build parameter title string for plots."""
145
+ """Build parameter title string for plots, handling optional parameter groups."""
145
146
  title_prefix = "* " if is_best else ""
146
- return (
147
- f"{title_prefix}reflection = "
148
- f"{pars['mirror_reflection_random_angle'][0]:.5f}, "
149
- f"{pars['mirror_reflection_random_angle'][1]:.5f}, "
150
- f"{pars['mirror_reflection_random_angle'][2]:.5f}\n"
151
- f"align_vertical = {pars['mirror_align_random_vertical'][0]:.5f}, "
152
- f"{pars['mirror_align_random_vertical'][1]:.5f}, "
153
- f"{pars['mirror_align_random_vertical'][2]:.5f}, "
154
- f"{pars['mirror_align_random_vertical'][3]:.5f}\n"
155
- f"align_horizontal = {pars['mirror_align_random_horizontal'][0]:.5f}, "
156
- f"{pars['mirror_align_random_horizontal'][1]:.5f}, "
157
- f"{pars['mirror_align_random_horizontal'][2]:.5f}, "
158
- f"{pars['mirror_align_random_horizontal'][3]:.5f}"
159
- )
147
+ title_lines = []
148
+
149
+ if "mirror_reflection_random_angle" in pars:
150
+ refl = pars["mirror_reflection_random_angle"]
151
+ title_lines.append(f"reflection = {refl[0]:.5f}, {refl[1]:.5f}, {refl[2]:.5f}")
152
+
153
+ if "mirror_align_random_vertical" in pars:
154
+ vert = pars["mirror_align_random_vertical"]
155
+ title_lines.append(
156
+ f"align_vertical = {vert[0]:.5f}, {vert[1]:.5f}, {vert[2]:.5f}, {vert[3]:.5f}"
157
+ )
158
+
159
+ if "mirror_align_random_horizontal" in pars:
160
+ horiz = pars["mirror_align_random_horizontal"]
161
+ title_lines.append(
162
+ f"align_horizontal = {horiz[0]:.5f}, {horiz[1]:.5f}, {horiz[2]:.5f}, {horiz[3]:.5f}"
163
+ )
164
+
165
+ return title_prefix + "\n".join(title_lines)
160
166
 
161
167
 
162
168
  def _add_metric_text_box(ax, metrics_text, is_best):
@@ -640,7 +646,7 @@ def create_psf_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, outp
640
646
  logger.info(f"Creating {psf_label_cm} vs off-axis angle plot with best parameters...")
641
647
 
642
648
  # Apply best parameters to telescope model
643
- tel_model.overwrite_parameters(best_pars)
649
+ tel_model.overwrite_parameters(best_pars, flat_dict=True)
644
650
 
645
651
  # Create off-axis angle array
646
652
  max_offset = args_dict.get("max_offset", MAX_OFFSET_DEFAULT)
@@ -654,6 +660,7 @@ def create_psf_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, outp
654
660
  ray = RayTracing(
655
661
  telescope_model=tel_model,
656
662
  site_model=site_model,
663
+ label=args_dict.get("label") or getattr(tel_model, "label", None),
657
664
  zenith_angle=args_dict["zenith"] * u.deg,
658
665
  source_distance=args_dict["src_distance"] * u.km,
659
666
  off_axis_angle=off_axis_angles * u.deg,
@@ -661,9 +668,9 @@ def create_psf_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, outp
661
668
 
662
669
  logger.info(f"Running ray tracing for {len(off_axis_angles)} off-axis angles...")
663
670
  ray.simulate(test=args_dict.get("test", False), force=True)
664
- ray.analyze(force=True)
671
+ ray.analyze(force=True, containment_fraction=fraction)
665
672
 
666
- for key in ["d80_cm", "d80_deg"]:
673
+ for key in ["psf_cm", "psf_deg"]:
667
674
  plt.figure(figsize=(10, 6), tight_layout=True)
668
675
 
669
676
  ray.plot(key, marker="o", linestyle="-", color="blue", linewidth=2, markersize=6)
@@ -772,3 +779,138 @@ def create_optimization_plots(args_dict, gd_results, tel_model, data_to_plot, ou
772
779
  use_ks_statistic=False,
773
780
  )
774
781
  pdf_pages.close()
782
+
783
+
784
+ def create_summary_psf_comparison_plot(
785
+ tel_model, optimized_params, data_to_plot, output_dir, final_rmsd, simulated_data
786
+ ):
787
+ """
788
+ Create a standalone plot comparing measured vs simulated PSF with final optimized parameters.
789
+
790
+ This creates a single plot showing the cumulative PSF comparison
791
+ before and after the optimization.
792
+
793
+ Parameters
794
+ ----------
795
+ tel_model : TelescopeModel
796
+ Telescope model with optimized parameters
797
+ optimized_params : dict
798
+ Dictionary of optimized parameter values
799
+ data_to_plot : dict
800
+ Measured PSF data
801
+ output_dir : Path
802
+ Directory for output files
803
+ final_rmsd : float
804
+ Final RMSD value at the end of optimization
805
+ simulated_data : dict
806
+ Final simulated PSF data with optimized parameters
807
+
808
+ Returns
809
+ -------
810
+ Path
811
+ Path to the created plot file
812
+ """
813
+ fig, ax = _create_base_plot_figure(data_to_plot, simulated_data)
814
+
815
+ title_lines = ["Final Optimized Parameters:"]
816
+ if "mirror_reflection_random_angle" in optimized_params:
817
+ refl = optimized_params["mirror_reflection_random_angle"]
818
+ title_lines.append(
819
+ f"mirror_reflection_random_angle = [{refl[0]:.6f}, {refl[1]:.6f}, {refl[2]:.6f}]"
820
+ )
821
+
822
+ if "mirror_align_random_vertical" in optimized_params:
823
+ vert = optimized_params["mirror_align_random_vertical"]
824
+ title_lines.append(
825
+ f"mirror_align_random_vertical = "
826
+ f"[{vert[0]:.6f}, {vert[1]:.6f}, {vert[2]:.6f}, {vert[3]:.6f}]"
827
+ )
828
+
829
+ if "mirror_align_random_horizontal" in optimized_params:
830
+ horiz = optimized_params["mirror_align_random_horizontal"]
831
+ title_lines.append(
832
+ f"mirror_align_random_horizontal = "
833
+ f"[{horiz[0]:.6f}, {horiz[1]:.6f}, {horiz[2]:.6f}, {horiz[3]:.6f}]"
834
+ )
835
+
836
+ ax.set_title("\n".join(title_lines), fontsize=9, loc="left")
837
+
838
+ rmsd_text = f"RMSD = {final_rmsd:.4f} ({final_rmsd * 100:.2f}%)"
839
+ ax.text(
840
+ 0.98,
841
+ 0.02,
842
+ rmsd_text,
843
+ transform=ax.transAxes,
844
+ fontsize=12,
845
+ verticalalignment="bottom",
846
+ horizontalalignment="right",
847
+ bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.8},
848
+ )
849
+
850
+ output_file = Path(output_dir) / f"{tel_model.name}_final_psf_comparison.png"
851
+ fig.savefig(output_file, dpi=150, bbox_inches="tight")
852
+ plt.close(fig)
853
+
854
+ logger.info(f"Final PSF comparison plot saved to {output_file}")
855
+ return output_file
856
+
857
+
858
+ def plot_psf_histogram(measured, simulated, args_dict):
859
+ """Write histogram comparing measured vs simulated PSF diameter distributions."""
860
+ output_dir = Path(args_dict.get("output_path", "."))
861
+ out_name = args_dict.get("psf_hist")
862
+ if not out_name:
863
+ return None
864
+ out_path = Path(out_name)
865
+ if not out_path.is_absolute():
866
+ out_path = output_dir / out_path
867
+ out_path.parent.mkdir(parents=True, exist_ok=True)
868
+ measured = np.asarray(measured, dtype=float)
869
+ simulated = np.asarray(simulated, dtype=float)
870
+ measured = measured[np.isfinite(measured)]
871
+ simulated = simulated[np.isfinite(simulated)]
872
+ if measured.size == 0 or simulated.size == 0:
873
+ return None
874
+ bins = 25
875
+ all_vals = np.concatenate([measured, simulated])
876
+ x_min = float(np.nanmin(all_vals))
877
+ x_max = float(np.nanmax(all_vals))
878
+ if not np.isfinite(x_min) or not np.isfinite(x_max) or x_max <= x_min:
879
+ return None
880
+ bin_edges = np.linspace(x_min, x_max, bins + 1)
881
+ meas_mean = float(np.mean(measured))
882
+ meas_rms = float(np.std(measured, ddof=0))
883
+ sim_mean = float(np.mean(simulated))
884
+ sim_rms = float(np.std(simulated, ddof=0))
885
+ fig, ax = plt.subplots(figsize=(7.5, 4.5), constrained_layout=True)
886
+ ax.hist(
887
+ measured,
888
+ bins=bin_edges,
889
+ alpha=0.55,
890
+ color="tab:red",
891
+ edgecolor="white",
892
+ label=f"Measured (mean={meas_mean:.2f} mm, rms={meas_rms:.2f} mm)",
893
+ )
894
+ ax.hist(
895
+ simulated,
896
+ bins=bin_edges,
897
+ alpha=0.55,
898
+ color="tab:blue",
899
+ edgecolor="white",
900
+ label=f"Simulated (mean={sim_mean:.2f} mm, rms={sim_rms:.2f} mm)",
901
+ )
902
+ ax.axvline(meas_mean, color="tab:red", linestyle="--", linewidth=1)
903
+ ax.axvline(sim_mean, color="tab:blue", linestyle="--", linewidth=1)
904
+ tel = args_dict.get("telescope", "")
905
+ model_version = args_dict.get("model_version", "")
906
+ fraction = args_dict.get("fraction")
907
+ label = get_psf_diameter_label(fraction, unit="mm") if fraction is not None else "PSF"
908
+ suffix = " ".join([s for s in (tel, model_version) if s])
909
+ ax.set_xlabel(label)
910
+ ax.set_ylabel("Count")
911
+ ax.set_title(f"{label} ({suffix})" if suffix else label)
912
+ ax.legend(loc="best", fontsize=9, frameon=True)
913
+ fig.savefig(out_path)
914
+ plt.close(fig)
915
+ logger.info("PSF histogram written to %s", str(out_path))
916
+ return str(out_path)