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.
- {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/METADATA +1 -3
- {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/RECORD +54 -51
- {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/entry_points.txt +3 -3
- simtools/_version.py +2 -2
- simtools/applications/calculate_incident_angles.py +182 -0
- simtools/applications/db_add_simulation_model_from_repository_to_db.py +17 -14
- simtools/applications/db_add_value_from_json_to_db.py +6 -9
- simtools/applications/db_generate_compound_indexes.py +7 -3
- simtools/applications/db_get_file_from_db.py +11 -23
- simtools/applications/derive_psf_parameters.py +58 -39
- simtools/applications/derive_trigger_rates.py +91 -0
- simtools/applications/generate_corsika_histograms.py +7 -184
- simtools/applications/maintain_simulation_model_add_production.py +105 -0
- simtools/applications/plot_simtel_events.py +5 -189
- simtools/applications/print_version.py +8 -7
- simtools/applications/validate_file_using_schema.py +7 -4
- simtools/configuration/commandline_parser.py +17 -11
- simtools/corsika/corsika_histograms.py +81 -0
- simtools/data_model/validate_data.py +8 -3
- simtools/db/db_handler.py +122 -31
- simtools/db/db_model_upload.py +51 -30
- simtools/dependencies.py +10 -5
- simtools/layout/array_layout_utils.py +37 -5
- simtools/model/array_model.py +18 -1
- simtools/model/model_repository.py +118 -63
- simtools/model/site_model.py +25 -0
- simtools/production_configuration/derive_corsika_limits.py +9 -34
- simtools/ray_tracing/incident_angles.py +706 -0
- simtools/ray_tracing/psf_parameter_optimisation.py +999 -565
- simtools/schemas/model_parameter_and_data_schema.metaschema.yml +2 -2
- simtools/schemas/model_parameters/nsb_reference_spectrum.schema.yml +1 -1
- simtools/schemas/model_parameters/nsb_spectrum.schema.yml +22 -29
- simtools/schemas/model_parameters/stars.schema.yml +1 -1
- simtools/schemas/production_tables.schema.yml +5 -0
- simtools/simtel/simtel_config_writer.py +18 -20
- simtools/simtel/simtel_io_event_histograms.py +253 -516
- simtools/simtel/simtel_io_event_reader.py +51 -2
- simtools/simtel/simtel_io_event_writer.py +31 -11
- simtools/simtel/simtel_io_metadata.py +1 -1
- simtools/simtel/simtel_table_reader.py +3 -3
- simtools/simulator.py +1 -4
- simtools/telescope_trigger_rates.py +119 -0
- simtools/testing/log_inspector.py +13 -11
- simtools/utils/geometry.py +20 -0
- simtools/version.py +89 -0
- simtools/{corsika/corsika_histograms_visualize.py → visualization/plot_corsika_histograms.py} +109 -0
- simtools/visualization/plot_incident_angles.py +431 -0
- simtools/visualization/plot_psf.py +673 -0
- simtools/visualization/plot_simtel_event_histograms.py +376 -0
- simtools/visualization/{simtel_event_plots.py → plot_simtel_events.py} +284 -87
- simtools/visualization/visualize.py +1 -3
- simtools/applications/calculate_trigger_rate.py +0 -187
- simtools/applications/generate_sim_telarray_histograms.py +0 -196
- simtools/applications/maintain_simulation_model_add_production_table.py +0 -71
- simtools/simtel/simtel_io_histogram.py +0 -623
- simtools/simtel/simtel_io_histograms.py +0 -556
- {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/WHEEL +0 -0
- {gammasimtools-0.19.0.dist-info → gammasimtools-0.21.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
+
)
|