gammasimtools 0.19.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.
Files changed (59) hide show
  1. {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/METADATA +1 -3
  2. {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/RECORD +54 -51
  3. {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/entry_points.txt +3 -3
  4. simtools/_version.py +2 -2
  5. simtools/applications/calculate_incident_angles.py +182 -0
  6. simtools/applications/db_add_simulation_model_from_repository_to_db.py +17 -14
  7. simtools/applications/db_add_value_from_json_to_db.py +6 -9
  8. simtools/applications/db_generate_compound_indexes.py +7 -3
  9. simtools/applications/db_get_file_from_db.py +11 -23
  10. simtools/applications/derive_psf_parameters.py +58 -39
  11. simtools/applications/derive_trigger_rates.py +91 -0
  12. simtools/applications/generate_corsika_histograms.py +7 -184
  13. simtools/applications/maintain_simulation_model_add_production.py +105 -0
  14. simtools/applications/plot_simtel_events.py +5 -189
  15. simtools/applications/print_version.py +8 -7
  16. simtools/applications/validate_file_using_schema.py +7 -4
  17. simtools/configuration/commandline_parser.py +17 -11
  18. simtools/corsika/corsika_histograms.py +81 -0
  19. simtools/data_model/validate_data.py +8 -3
  20. simtools/db/db_handler.py +122 -31
  21. simtools/db/db_model_upload.py +51 -30
  22. simtools/dependencies.py +10 -5
  23. simtools/layout/array_layout_utils.py +37 -5
  24. simtools/model/array_model.py +18 -1
  25. simtools/model/model_repository.py +118 -63
  26. simtools/model/site_model.py +25 -0
  27. simtools/production_configuration/derive_corsika_limits.py +9 -34
  28. simtools/ray_tracing/incident_angles.py +706 -0
  29. simtools/ray_tracing/psf_parameter_optimisation.py +999 -565
  30. simtools/schemas/model_parameter_and_data_schema.metaschema.yml +2 -2
  31. simtools/schemas/model_parameters/nsb_reference_spectrum.schema.yml +1 -1
  32. simtools/schemas/model_parameters/nsb_spectrum.schema.yml +22 -29
  33. simtools/schemas/model_parameters/stars.schema.yml +1 -1
  34. simtools/schemas/production_tables.schema.yml +5 -0
  35. simtools/simtel/simtel_config_writer.py +18 -20
  36. simtools/simtel/simtel_io_event_histograms.py +253 -516
  37. simtools/simtel/simtel_io_event_reader.py +51 -2
  38. simtools/simtel/simtel_io_event_writer.py +31 -11
  39. simtools/simtel/simtel_io_metadata.py +1 -1
  40. simtools/simtel/simtel_table_reader.py +3 -3
  41. simtools/simulator.py +1 -4
  42. simtools/telescope_trigger_rates.py +119 -0
  43. simtools/testing/log_inspector.py +13 -11
  44. simtools/utils/geometry.py +20 -0
  45. simtools/version.py +89 -0
  46. simtools/{corsika/corsika_histograms_visualize.py → visualization/plot_corsika_histograms.py} +109 -0
  47. simtools/visualization/plot_incident_angles.py +431 -0
  48. simtools/visualization/plot_psf.py +673 -0
  49. simtools/visualization/plot_simtel_event_histograms.py +376 -0
  50. simtools/visualization/{simtel_event_plots.py → plot_simtel_events.py} +284 -87
  51. simtools/visualization/visualize.py +1 -3
  52. simtools/applications/calculate_trigger_rate.py +0 -187
  53. simtools/applications/generate_sim_telarray_histograms.py +0 -196
  54. simtools/applications/maintain_simulation_model_add_production_table.py +0 -71
  55. simtools/simtel/simtel_io_histogram.py +0 -623
  56. simtools/simtel/simtel_io_histograms.py +0 -556
  57. {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/WHEEL +0 -0
  58. {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/licenses/LICENSE +0 -0
  59. {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,706 @@
1
+ """Calculate photon incident angles on focal plane and primary/secondary mirrors.
2
+
3
+ Parses the imaging list (``.lis``) produced by sim_telarray_debug_trace and uses
4
+ Angle of incidence at focal surface, with respect to the optical axis [deg],
5
+ Angle of incidence on to primary mirror [deg], and
6
+ Angle of incidence on to secondary mirror [deg] (if available).
7
+ """
8
+
9
+ import logging
10
+ import math
11
+ import re
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ import astropy.units as u
16
+ from astropy.table import QTable
17
+
18
+ from simtools.data_model.metadata_collector import MetadataCollector
19
+ from simtools.model.model_utils import initialize_simulation_models
20
+
21
+
22
+ class IncidentAnglesCalculator:
23
+ """Run a PSF-style sim_telarray job and compute incident angles at mirrors or focal surfaces.
24
+
25
+ Parameters
26
+ ----------
27
+ simtel_path : str or pathlib.Path
28
+ Path to the sim_telarray installation directory (containing ``sim_telarray/bin``).
29
+ db_config : dict
30
+ Database configuration passed to ``initialize_simulation_models``.
31
+ config_data : dict
32
+ Simulation configuration (e.g. ``site``, ``telescope``, ``model_version``,
33
+ ``off_axis_angle``, ``source_distance``, ``number_of_photons``).
34
+ output_dir : str or pathlib.Path
35
+ Output directory where logs, scripts, photons files and results are written.
36
+ label : str, optional
37
+ Label used to name outputs; defaults to ``incident_angles_<telescope>`` when omitted.
38
+
39
+ Notes
40
+ -----
41
+ Additional options are read from ``config_data`` when present:
42
+ - ``perfect_mirror`` (bool, default False)
43
+ - ``calculate_primary_secondary_angles`` (bool, default True)
44
+ """
45
+
46
+ # Use fixed zenith angle (degrees) for incident-angle simulations.
47
+ ZENITH_ANGLE_DEG = 0
48
+
49
+ def __init__(
50
+ self,
51
+ simtel_path,
52
+ db_config,
53
+ config_data,
54
+ output_dir,
55
+ label=None,
56
+ ):
57
+ self.logger = logging.getLogger(__name__)
58
+
59
+ self._simtel_path = Path(simtel_path)
60
+ self.config_data = config_data
61
+ self.output_dir = Path(output_dir)
62
+ self.label = label or f"incident_angles_{config_data['telescope']}"
63
+ cfg = config_data
64
+ self.perfect_mirror = cfg.get("perfect_mirror", False)
65
+ self.calculate_primary_secondary_angles = cfg.get(
66
+ "calculate_primary_secondary_angles", True
67
+ )
68
+ self.results = None
69
+
70
+ self.output_dir.mkdir(parents=True, exist_ok=True)
71
+ self.logs_dir = self.output_dir / "logs"
72
+ self.scripts_dir = self.output_dir / "scripts"
73
+ self.photons_dir = self.output_dir / "photons_files"
74
+ self.results_dir = self.output_dir / "incident_angles"
75
+ for d in (self.logs_dir, self.scripts_dir, self.photons_dir, self.results_dir):
76
+ d.mkdir(parents=True, exist_ok=True)
77
+
78
+ self.logger.info(
79
+ "Initializing models for %s, %s",
80
+ config_data["site"],
81
+ config_data["telescope"],
82
+ )
83
+ self.telescope_model, self.site_model = initialize_simulation_models(
84
+ self.label,
85
+ db_config,
86
+ config_data["site"],
87
+ config_data["telescope"],
88
+ config_data["model_version"],
89
+ )
90
+
91
+ def _label_suffix(self):
92
+ """Build a filename suffix including telescope and off-axis angle.
93
+
94
+ Returns
95
+ -------
96
+ str
97
+ Suffix of the form ``"<label>_<telescope>_off<angle>"`` where
98
+ ``<angle>`` is formatted without trailing zeros.
99
+ """
100
+ tel = str(self.config_data.get("telescope", "TEL"))
101
+ off = float(self.config_data.get("off_axis_angle", 0.0 * u.deg).to_value(u.deg))
102
+ return f"{self.label}_{tel}_off{off:g}"
103
+
104
+ def run(self):
105
+ """Run sim_telarray, parse the imaging list, and return an angle table.
106
+
107
+ Returns
108
+ -------
109
+ astropy.table.QTable
110
+ Table containing at least the ``angle_incidence_focal`` column
111
+ and, when configured, primary/secondary angles and hit geometry.
112
+ """
113
+ self.telescope_model.write_sim_telarray_config_file(additional_model=self.site_model)
114
+
115
+ photons_file, stars_file, log_file = self._prepare_psf_io_files()
116
+ run_script = self._write_run_script(photons_file, stars_file, log_file)
117
+ self._run_script(run_script, log_file)
118
+
119
+ data = self._compute_incidence_angles_from_imaging_list(photons_file)
120
+ self.results = QTable()
121
+ self.results["angle_incidence_focal"] = data["angle_incidence_focal_deg"] * u.deg
122
+ if self.calculate_primary_secondary_angles:
123
+ field_map = {
124
+ "angle_incidence_primary_deg": ("angle_incidence_primary", u.deg),
125
+ "angle_incidence_secondary_deg": ("angle_incidence_secondary", u.deg),
126
+ "primary_hit_radius_m": ("primary_hit_radius", u.m),
127
+ "secondary_hit_radius_m": ("secondary_hit_radius", u.m),
128
+ "primary_hit_x_m": ("primary_hit_x", u.m),
129
+ "primary_hit_y_m": ("primary_hit_y", u.m),
130
+ "secondary_hit_x_m": ("secondary_hit_x", u.m),
131
+ "secondary_hit_y_m": ("secondary_hit_y", u.m),
132
+ }
133
+ for key, (name, unit) in field_map.items():
134
+ if key in data:
135
+ self.results[name] = data[key] * unit
136
+
137
+ self._save_results()
138
+ return self.results
139
+
140
+ def run_for_offsets(self, offsets):
141
+ """Run the simulation for multiple off-axis angles.
142
+
143
+ For each off-axis angle provided, run a full simulation, labeling output files
144
+ accordingly.
145
+
146
+ Parameters
147
+ ----------
148
+ offsets : Iterable[float]
149
+ Off-axis angles in degrees.
150
+
151
+ Returns
152
+ -------
153
+ dict[float, astropy.table.QTable]
154
+ Mapping from off-axis angle (deg) to the resulting table.
155
+ """
156
+ results_by_offset = {}
157
+ base_off = self.config_data.get("off_axis_angle", 0.0 * u.deg)
158
+
159
+ for off in offsets:
160
+ self.config_data["off_axis_angle"] = float(off) * u.deg
161
+ self.logger.info(f"Running for off-axis angle {off:g} deg")
162
+ tbl = self.run()
163
+ results_by_offset[float(off)] = tbl.copy()
164
+
165
+ self.config_data["off_axis_angle"] = base_off
166
+ return results_by_offset
167
+
168
+ def _prepare_psf_io_files(self):
169
+ """Prepare photons, stars, and log file paths for a PSF-style incident angle simulation.
170
+
171
+ Returns
172
+ -------
173
+ tuple[pathlib.Path, pathlib.Path, pathlib.Path]
174
+ Paths to the photons file, stars file, and log file.
175
+ """
176
+ suffix = self._label_suffix()
177
+ photons_file = self.photons_dir / f"incident_angles_photons_{suffix}.lis"
178
+ stars_file = self.photons_dir / f"incident_angles_stars_{suffix}.lis"
179
+ log_file = self.logs_dir / f"incident_angles_{suffix}.log"
180
+
181
+ if photons_file.exists():
182
+ try:
183
+ photons_file.unlink()
184
+ except OSError as err:
185
+ self.logger.error(f"Failed to remove existing photons file {photons_file}: {err}")
186
+
187
+ with photons_file.open("w", encoding="utf-8") as pf:
188
+ pf.write(f"#{'=' * 50}\n")
189
+ pf.write("# Imaging list for Incident Angle simulations\n")
190
+ pf.write(f"#{'=' * 50}\n")
191
+ pf.write(f"# config_file = {self.telescope_model.config_file_path}\n")
192
+ pf.write(f"# zenith_angle [deg] = {self.ZENITH_ANGLE_DEG}\n")
193
+ pf.write(
194
+ f"# off_axis_angle [deg] = {self.config_data['off_axis_angle'].to_value(u.deg)}\n"
195
+ )
196
+ pf.write(f"# source_distance [km] = {self.config_data['source_distance']}\n")
197
+
198
+ with stars_file.open("w", encoding="utf-8") as sf:
199
+ zen = self.ZENITH_ANGLE_DEG
200
+ dist = float(self.config_data["source_distance"])
201
+ sf.write(f"0. {90.0 - zen} 1.0 {dist}\n")
202
+
203
+ return photons_file, stars_file, log_file
204
+
205
+ def _write_run_script(self, photons_file, stars_file, log_file):
206
+ """Generate a run script for sim_telarray with the provided configuration and inputs.
207
+
208
+ Parameters
209
+ ----------
210
+ photons_file, stars_file, log_file : pathlib.Path
211
+ Input/output files for the run.
212
+
213
+ Returns
214
+ -------
215
+ pathlib.Path
216
+ Path to the generated shell script.
217
+ """
218
+ script_path = self.scripts_dir / f"run_incident_angles_{self._label_suffix()}.sh"
219
+ simtel_bin = self._simtel_path / "sim_telarray/bin/sim_telarray_debug_trace"
220
+ corsika_dummy = self._simtel_path / "sim_telarray/run9991.corsika.gz"
221
+
222
+ theta = self.ZENITH_ANGLE_DEG
223
+ off = float(self.config_data["off_axis_angle"].to_value(u.deg))
224
+ star_photons = self.config_data["number_of_photons"]
225
+
226
+ def cfg(par, val):
227
+ return f"-C {par}={val}"
228
+
229
+ opts = [
230
+ f"-c {self.telescope_model.config_file_path}",
231
+ f"-I{self.telescope_model.config_file_directory}",
232
+ ]
233
+ if self.perfect_mirror:
234
+ opts += [
235
+ "-DPERFECT_DISH=1",
236
+ "-C random_focal_length=0",
237
+ "-C mirror_reflection_random_angle=0",
238
+ "-C mirror_align_random_distance=0",
239
+ "-C mirror_align_random_horizontal=0,28,0,0",
240
+ "-C mirror_align_random_vertical=0,28,0,0",
241
+ ]
242
+
243
+ opts += [
244
+ cfg("IMAGING_LIST", str(photons_file)),
245
+ cfg("stars", str(stars_file)),
246
+ cfg("altitude", self.site_model.get_parameter_value("corsika_observation_level")),
247
+ cfg("telescope_theta", theta + off),
248
+ cfg("star_photons", star_photons),
249
+ cfg("telescope_phi", 0),
250
+ cfg("camera_transmission", 1.0),
251
+ cfg("nightsky_background", "all:0."),
252
+ cfg("trigger_current_limit", "1e10"),
253
+ cfg("telescope_random_angle", 0),
254
+ cfg("telescope_random_error", 0),
255
+ cfg("convergent_depth", 0),
256
+ cfg("maximum_telescopes", 1),
257
+ cfg("show", "all"),
258
+ cfg("camera_filter", "none"),
259
+ ]
260
+
261
+ command = f"{simtel_bin} {' '.join(opts)} {corsika_dummy}"
262
+ with script_path.open("w", encoding="utf-8") as sh:
263
+ sh.write("#!/usr/bin/env bash\n\n")
264
+ sh.write("set -e\nset -o pipefail\n\n")
265
+ sh.write(f"exec > '{log_file}' 2>&1\n\n")
266
+ sh.write(f"{command}\n")
267
+ script_path.chmod(script_path.stat().st_mode | 0o110)
268
+ return script_path
269
+
270
+ def _run_script(self, script_path, log_file):
271
+ """Execute the script and log output; raise an error if execution fails.
272
+
273
+ Parameters
274
+ ----------
275
+ script_path : pathlib.Path
276
+ Path to the script to execute.
277
+ log_file : pathlib.Path
278
+ Destination log file.
279
+ """
280
+ self.logger.info("Executing %s (logging to %s)", script_path, log_file)
281
+ try:
282
+ subprocess.check_call([str(script_path)])
283
+ except subprocess.CalledProcessError as exc:
284
+ raise RuntimeError(f"Incident angles run failed, see log: {log_file}") from exc
285
+
286
+ def _compute_incidence_angles_from_imaging_list(self, photons_file):
287
+ """Compute incidence angles from an imaging list file.
288
+
289
+ Column positions may differ between telescope types and sim_telarray versions.
290
+ Header lines (``# Column N: ...``) are parsed to find indices; otherwise
291
+ legacy positions (1-based) are used: focal=26, primary=32, secondary=36,
292
+ primary X/Y = 29/30, secondary X/Y = 33/34.
293
+
294
+ Parameters
295
+ ----------
296
+ photons_file : pathlib.Path
297
+ Path to the imaging list file (``.lis``).
298
+
299
+ Returns
300
+ -------
301
+ dict[str, list[float]]
302
+ Parsed columns in degrees/meters as plain Python lists. Always
303
+ contains ``angle_incidence_focal_deg``; additional keys are present
304
+ when primary/secondary angles are enabled.
305
+ """
306
+ col_idx = self._find_column_indices(photons_file)
307
+
308
+ focal = []
309
+ # Initialize optional arrays once based on the configuration
310
+ primary = secondary = radius_m = secondary_radius_m = None
311
+ primary_hit_x_m = primary_hit_y_m = secondary_hit_x_m = secondary_hit_y_m = None
312
+ if self.calculate_primary_secondary_angles:
313
+ primary, secondary = [], []
314
+ radius_m, secondary_radius_m = [], []
315
+ primary_hit_x_m, primary_hit_y_m = [], []
316
+ secondary_hit_x_m, secondary_hit_y_m = [], []
317
+
318
+ for parts in self._iter_data_rows(photons_file):
319
+ self._append_values(
320
+ parts,
321
+ col_idx,
322
+ focal,
323
+ primary,
324
+ secondary,
325
+ radius_m,
326
+ secondary_radius_m,
327
+ primary_hit_x_m,
328
+ primary_hit_y_m,
329
+ secondary_hit_x_m,
330
+ secondary_hit_y_m,
331
+ )
332
+
333
+ result = {"angle_incidence_focal_deg": focal}
334
+ if self.calculate_primary_secondary_angles:
335
+ result["angle_incidence_primary_deg"] = primary
336
+ result["angle_incidence_secondary_deg"] = secondary
337
+ result["primary_hit_radius_m"] = radius_m
338
+ result["secondary_hit_radius_m"] = secondary_radius_m
339
+ result["primary_hit_x_m"] = primary_hit_x_m
340
+ result["primary_hit_y_m"] = primary_hit_y_m
341
+ result["secondary_hit_x_m"] = secondary_hit_x_m
342
+ result["secondary_hit_y_m"] = secondary_hit_y_m
343
+ return result
344
+
345
+ def _find_column_indices(self, photons_file):
346
+ """Return 0-based column indices found from headers as a dict.
347
+
348
+ Returns a mapping with keys ``'focal'`` and, when applicable, ``'primary'``,
349
+ ``'secondary'``, ``'prim_x'``, ``'prim_y'``, ``'sec_x'``, ``'sec_y'``.
350
+
351
+ Parameters
352
+ ----------
353
+ photons_file : pathlib.Path
354
+ Imaging list file whose headers may define column numbers.
355
+
356
+ Returns
357
+ -------
358
+ dict[str, int]
359
+ 0-based indices for the required columns.
360
+ """
361
+ indices = self._default_column_indices()
362
+
363
+ col_pat = re.compile(r"^\s*#\s*Column\s+(\d{1,4})\s*:(.*)$", re.IGNORECASE)
364
+ with photons_file.open("r", encoding="utf-8") as fh:
365
+ for raw in fh:
366
+ s = raw.strip()
367
+ if not s or not s.startswith("#"):
368
+ continue
369
+ m = col_pat.match(s)
370
+ if not m:
371
+ continue
372
+ num = int(m.group(1))
373
+ desc = m.group(2).strip().lower()
374
+ self._update_indices_from_header_desc(desc, num, indices)
375
+
376
+ return indices
377
+
378
+ def _default_column_indices(self):
379
+ """Return default 0-based indices matching SST-like photon files.
380
+
381
+ Fallbacks (1-based): focal=26, primary=32, secondary=36,
382
+ primary X/Y=29/30, secondary X/Y=33/34.
383
+
384
+ Returns
385
+ -------
386
+ dict[str, int]
387
+ Default index mapping. When primary/secondary angles are disabled,
388
+ only ``'focal'`` is included.
389
+ """
390
+ idx = {"focal": 25}
391
+ if self.calculate_primary_secondary_angles:
392
+ idx.update(
393
+ {
394
+ "primary": 31,
395
+ "secondary": 35,
396
+ "prim_x": 28,
397
+ "prim_y": 29,
398
+ "sec_x": 32,
399
+ "sec_y": 33,
400
+ }
401
+ )
402
+ return idx
403
+
404
+ def _update_indices_from_header_desc(self, desc, num, indices):
405
+ """Update indices dict in-place based on a header description and column number.
406
+
407
+ Parameters
408
+ ----------
409
+ desc : str
410
+ Header description text (lower-cased).
411
+ num : int
412
+ 1-based column number from the header.
413
+ indices : dict[str, int]
414
+ Mapping to update in-place.
415
+ """
416
+ # Angles
417
+ if "angle of incidence" in desc:
418
+ if "focal surface" in desc:
419
+ indices["focal"] = num - 1
420
+ return
421
+ if self.calculate_primary_secondary_angles:
422
+ if "primary mirror" in desc:
423
+ indices["primary"] = num - 1
424
+ return
425
+ if "secondary mirror" in desc:
426
+ indices["secondary"] = num - 1
427
+ return
428
+ # Reflection points (X/Y)
429
+ if not self.calculate_primary_secondary_angles or "reflection point" not in desc:
430
+ return
431
+ self._set_reflection_index_if_match(desc, num, indices)
432
+
433
+ @staticmethod
434
+ def _contains_axis(desc, axis):
435
+ """Check whether a description contains a stand-alone axis label.
436
+
437
+ Parameters
438
+ ----------
439
+ desc : str
440
+ Header description string.
441
+ axis : str
442
+ Either ``"x"`` or ``"y"``.
443
+
444
+ Returns
445
+ -------
446
+ bool
447
+ True when the token is present as a separate word; False otherwise.
448
+ """
449
+ desc_l = desc.lower()
450
+ axis_l = axis.lower()
451
+ return bool(re.search(r"(^|\s)" + re.escape(axis_l) + r"(\s|$)", desc_l))
452
+
453
+ def _set_reflection_index_if_match(self, desc, num, indices):
454
+ """Set reflection point indices for primary/secondary mirrors if the header matches.
455
+
456
+ Parameters
457
+ ----------
458
+ desc : str
459
+ Header description string (lower-cased).
460
+ num : int
461
+ 1-based column number from the header.
462
+ indices : dict[str, int]
463
+ Mapping to update in-place with keys ``prim_x``, ``prim_y``,
464
+ ``sec_x``, or ``sec_y``.
465
+ """
466
+ is_primary = "primary mirror" in desc
467
+ is_secondary = "secondary mirror" in desc
468
+ if not is_primary and not is_secondary:
469
+ return
470
+ is_x = self._contains_axis(desc, "x")
471
+ is_y = self._contains_axis(desc, "y")
472
+ if not (is_x or is_y):
473
+ return
474
+ key_prefix = "prim" if is_primary else "sec"
475
+ key = f"{key_prefix}_{'x' if is_x else 'y'}"
476
+ indices[key] = num - 1
477
+
478
+ @staticmethod
479
+ def _parse_float(parts, idx):
480
+ """Try to parse a float from ``parts[idx]``.
481
+
482
+ Parameters
483
+ ----------
484
+ parts : list[str]
485
+ Tokenized row.
486
+ idx : int | None
487
+ Index into ``parts``.
488
+
489
+ Returns
490
+ -------
491
+ tuple[bool, float]
492
+ Tuple of ``(ok, value)`` where ``ok`` is False when parsing fails
493
+ or the index is out of range; in that case ``value`` is 0.0.
494
+ """
495
+ if idx is None or idx < 0 or idx >= len(parts):
496
+ return False, 0.0
497
+ try:
498
+ return True, float(parts[idx])
499
+ except ValueError:
500
+ return False, 0.0
501
+
502
+ @staticmethod
503
+ def _parse_float_with_nan(parts, idx):
504
+ """Parse a float or return NaN when missing/invalid.
505
+
506
+ Parameters
507
+ ----------
508
+ parts : list[str]
509
+ Tokenized row.
510
+ idx : int | None
511
+ Index into ``parts``.
512
+
513
+ Returns
514
+ -------
515
+ float
516
+ Parsed float value, or ``nan`` when unavailable/invalid.
517
+ """
518
+ if idx is None or idx < 0 or idx >= len(parts):
519
+ return float("nan")
520
+ try:
521
+ return float(parts[idx])
522
+ except ValueError:
523
+ return float("nan")
524
+
525
+ @staticmethod
526
+ def _iter_data_rows(photons_file):
527
+ """Iterate over tokenized, non-empty, non-comment rows.
528
+
529
+ Parameters
530
+ ----------
531
+ photons_file : pathlib.Path
532
+ Imaging list file to read.
533
+
534
+ Returns
535
+ -------
536
+ Iterator[list[str]]
537
+ Iterator over tokenized rows.
538
+ """
539
+ with photons_file.open("r", encoding="utf-8") as fh:
540
+ for line in fh:
541
+ if not line.strip() or line.lstrip().startswith("#"):
542
+ continue
543
+ yield line.split()
544
+
545
+ def _append_values(
546
+ self,
547
+ parts,
548
+ col_idx,
549
+ focal,
550
+ primary,
551
+ secondary,
552
+ radius_m,
553
+ secondary_radius_m,
554
+ primary_hit_x_m,
555
+ primary_hit_y_m,
556
+ secondary_hit_x_m,
557
+ secondary_hit_y_m,
558
+ ):
559
+ """Append parsed values from parts into target arrays if valid.
560
+
561
+ Parameters
562
+ ----------
563
+ parts : list[str]
564
+ Tokenized input row.
565
+ col_idx : dict[str, int]
566
+ Column indices used to read values.
567
+ focal, primary, secondary : list | None
568
+ Output arrays to append into.
569
+ radius_m, secondary_radius_m : list | None
570
+ Output arrays for radii in meters.
571
+ primary_hit_x_m, primary_hit_y_m, secondary_hit_x_m, secondary_hit_y_m : list | None
572
+ Output arrays for hit coordinates in meters.
573
+ """
574
+ foc_ok, foc_val = self._parse_float(parts, col_idx.get("focal"))
575
+ if not foc_ok:
576
+ return
577
+ focal.append(foc_val)
578
+ if not self.calculate_primary_secondary_angles:
579
+ return
580
+
581
+ self._append_primary_secondary_angles(parts, col_idx, primary, secondary)
582
+ self._append_primary_hit_geometry(
583
+ parts, col_idx, radius_m, primary_hit_x_m, primary_hit_y_m
584
+ )
585
+ self._append_secondary_hit_geometry(
586
+ parts, col_idx, secondary_radius_m, secondary_hit_x_m, secondary_hit_y_m
587
+ )
588
+
589
+ def _append_primary_secondary_angles(self, parts, col_idx, primary, secondary):
590
+ """Append primary/secondary angle values (or NaN) if arrays are provided.
591
+
592
+ Parameters
593
+ ----------
594
+ parts : list[str]
595
+ Tokenized input row.
596
+ col_idx : dict[str, int]
597
+ Indices for angle columns.
598
+ primary, secondary : list | None
599
+ Output arrays to append into.
600
+ """
601
+ if primary is not None:
602
+ primary.append(self._parse_float_with_nan(parts, col_idx.get("primary")))
603
+ if secondary is not None:
604
+ secondary.append(self._parse_float_with_nan(parts, col_idx.get("secondary")))
605
+
606
+ def _append_primary_hit_geometry(
607
+ self, parts, col_idx, radius_m, primary_hit_x_m, primary_hit_y_m
608
+ ):
609
+ """Append primary-mirror hit geometry (radius and x/y in meters).
610
+
611
+ Parameters
612
+ ----------
613
+ parts : list[str]
614
+ Tokenized input row.
615
+ col_idx : dict[str, int]
616
+ Indices for hit coordinate columns.
617
+ radius_m, primary_hit_x_m, primary_hit_y_m : list | None
618
+ Output arrays to append into.
619
+ """
620
+ x_ok, x_cm = self._parse_float(parts, col_idx.get("prim_x"))
621
+ y_ok, y_cm = self._parse_float(parts, col_idx.get("prim_y"))
622
+ if x_ok and y_ok:
623
+ x_m, y_m = x_cm / 100.0, y_cm / 100.0
624
+ r_m = math.hypot(x_cm, y_cm) / 100.0
625
+ else:
626
+ x_m = y_m = r_m = math.nan
627
+
628
+ if radius_m is not None:
629
+ radius_m.append(r_m)
630
+ if primary_hit_x_m is not None:
631
+ primary_hit_x_m.append(x_m)
632
+ if primary_hit_y_m is not None:
633
+ primary_hit_y_m.append(y_m)
634
+
635
+ def _append_secondary_hit_geometry(
636
+ self, parts, col_idx, secondary_radius_m, secondary_hit_x_m, secondary_hit_y_m
637
+ ):
638
+ """Append secondary-mirror hit geometry (radius and x/y in meters).
639
+
640
+ Parameters
641
+ ----------
642
+ parts : list[str]
643
+ Tokenized input row.
644
+ col_idx : dict[str, int]
645
+ Indices for hit coordinate columns.
646
+ secondary_radius_m, secondary_hit_x_m, secondary_hit_y_m : list | None
647
+ Output arrays to append into.
648
+ """
649
+ sx_ok, sx_cm = self._parse_float(parts, col_idx.get("sec_x"))
650
+ sy_ok, sy_cm = self._parse_float(parts, col_idx.get("sec_y"))
651
+ if sx_ok and sy_ok:
652
+ x_m, y_m = sx_cm / 100.0, sy_cm / 100.0
653
+ r_m = math.hypot(sx_cm, sy_cm) / 100.0
654
+ else:
655
+ x_m = y_m = r_m = math.nan
656
+
657
+ if secondary_radius_m is not None:
658
+ secondary_radius_m.append(r_m)
659
+ if secondary_hit_x_m is not None:
660
+ secondary_hit_x_m.append(x_m)
661
+ if secondary_hit_y_m is not None:
662
+ secondary_hit_y_m.append(y_m)
663
+
664
+ @staticmethod
665
+ def _match_header_column(col_pat, raw):
666
+ """Parse a header line for a known angle column.
667
+
668
+ Parameters
669
+ ----------
670
+ col_pat : Pattern[str]
671
+ Compiled regular expression matching ``# Column N`` prefix.
672
+ raw : str
673
+ Raw header line.
674
+
675
+ Returns
676
+ -------
677
+ tuple[str, int] | None
678
+ ``(kind, column_number)`` when recognized, otherwise ``None``.
679
+ """
680
+ s = raw.strip()
681
+ if s and ":" in s:
682
+ prefix, desc = s.split(":", 1)
683
+ m = col_pat.match(prefix)
684
+ if m:
685
+ num = int(m.group(1))
686
+ desc = desc.strip().lower()
687
+ if "angle of incidence at focal surface" in desc and "optical axis" in desc:
688
+ return "focal", num
689
+ if re.search(r"angle of incidence\s+on(to)?\s+primary mirror", desc):
690
+ return "primary", num
691
+ if re.search(r"angle of incidence\s+on(to)?\s+secondary mirror", desc):
692
+ return "secondary", num
693
+ return None
694
+
695
+ def _save_results(self):
696
+ """Save the results to an ECSV file with metadata."""
697
+ if self.results is None or len(self.results) == 0:
698
+ self.logger.warning("No results to save")
699
+ return
700
+ output_file = self.results_dir / f"incident_angles_{self._label_suffix()}.ecsv"
701
+ self.results.write(output_file, format="ascii.ecsv", overwrite=True)
702
+
703
+ MetadataCollector.dump(
704
+ args_dict=self.config_data,
705
+ output_file=output_file.with_suffix(".yml"),
706
+ )