gammasimtools 0.23.0__py3-none-any.whl → 0.25.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 (90) hide show
  1. {gammasimtools-0.23.0.dist-info → gammasimtools-0.25.0.dist-info}/METADATA +1 -1
  2. {gammasimtools-0.23.0.dist-info → gammasimtools-0.25.0.dist-info}/RECORD +89 -85
  3. {gammasimtools-0.23.0.dist-info → gammasimtools-0.25.0.dist-info}/entry_points.txt +1 -0
  4. simtools/_version.py +2 -2
  5. simtools/application_control.py +54 -4
  6. simtools/applications/convert_geo_coordinates_of_array_elements.py +1 -1
  7. simtools/applications/db_add_file_to_db.py +2 -2
  8. simtools/applications/db_add_simulation_model_from_repository_to_db.py +1 -1
  9. simtools/applications/db_add_value_from_json_to_db.py +2 -2
  10. simtools/applications/db_development_tools/write_array_elements_positions_to_repository.py +1 -1
  11. simtools/applications/db_generate_compound_indexes.py +1 -1
  12. simtools/applications/db_get_array_layouts_from_db.py +2 -2
  13. simtools/applications/db_get_file_from_db.py +1 -1
  14. simtools/applications/db_get_parameter_from_db.py +1 -1
  15. simtools/applications/db_inspect_databases.py +4 -2
  16. simtools/applications/db_upload_model_repository.py +1 -1
  17. simtools/applications/derive_ctao_array_layouts.py +1 -1
  18. simtools/applications/derive_psf_parameters.py +5 -0
  19. simtools/applications/derive_pulse_shape_parameters.py +195 -0
  20. simtools/applications/generate_array_config.py +1 -1
  21. simtools/applications/maintain_simulation_model_add_production.py +11 -21
  22. simtools/applications/plot_array_layout.py +63 -1
  23. simtools/applications/production_generate_grid.py +1 -1
  24. simtools/applications/simulate_flasher.py +3 -2
  25. simtools/applications/simulate_pedestals.py +1 -1
  26. simtools/applications/simulate_prod.py +8 -23
  27. simtools/applications/simulate_prod_htcondor_generator.py +7 -0
  28. simtools/applications/submit_array_layouts.py +7 -5
  29. simtools/applications/validate_camera_fov.py +1 -1
  30. simtools/applications/validate_cumulative_psf.py +2 -2
  31. simtools/applications/validate_file_using_schema.py +49 -123
  32. simtools/applications/validate_optics.py +1 -1
  33. simtools/configuration/commandline_parser.py +15 -15
  34. simtools/configuration/configurator.py +1 -1
  35. simtools/corsika/corsika_config.py +199 -91
  36. simtools/data_model/model_data_writer.py +15 -3
  37. simtools/data_model/schema.py +145 -36
  38. simtools/data_model/validate_data.py +82 -48
  39. simtools/db/db_handler.py +61 -294
  40. simtools/db/db_model_upload.py +3 -2
  41. simtools/db/mongo_db.py +626 -0
  42. simtools/dependencies.py +38 -17
  43. simtools/io/eventio_handler.py +128 -0
  44. simtools/job_execution/htcondor_script_generator.py +0 -2
  45. simtools/layout/array_layout.py +7 -7
  46. simtools/layout/array_layout_utils.py +4 -4
  47. simtools/model/array_model.py +72 -72
  48. simtools/model/calibration_model.py +12 -9
  49. simtools/model/model_parameter.py +196 -160
  50. simtools/model/model_repository.py +176 -39
  51. simtools/model/model_utils.py +3 -3
  52. simtools/model/site_model.py +59 -27
  53. simtools/model/telescope_model.py +21 -13
  54. simtools/ray_tracing/mirror_panel_psf.py +4 -4
  55. simtools/ray_tracing/psf_analysis.py +11 -8
  56. simtools/ray_tracing/psf_parameter_optimisation.py +823 -680
  57. simtools/reporting/docs_auto_report_generator.py +1 -1
  58. simtools/reporting/docs_read_parameters.py +72 -11
  59. simtools/runners/corsika_runner.py +12 -3
  60. simtools/runners/corsika_simtel_runner.py +6 -0
  61. simtools/runners/runner_services.py +17 -7
  62. simtools/runners/simtel_runner.py +12 -54
  63. simtools/schemas/model_parameters/flasher_pulse_exp_decay.schema.yml +2 -0
  64. simtools/schemas/model_parameters/flasher_pulse_shape.schema.yml +50 -0
  65. simtools/schemas/model_parameters/flasher_pulse_width.schema.yml +2 -0
  66. simtools/schemas/simulation_models_info.schema.yml +4 -1
  67. simtools/simtel/pulse_shapes.py +268 -0
  68. simtools/simtel/simtel_config_writer.py +179 -21
  69. simtools/simtel/simtel_io_event_writer.py +2 -2
  70. simtools/simtel/simulator_array.py +58 -12
  71. simtools/simtel/simulator_light_emission.py +45 -8
  72. simtools/simulator.py +361 -346
  73. simtools/testing/assertions.py +110 -10
  74. simtools/testing/configuration.py +1 -1
  75. simtools/testing/log_inspector.py +4 -1
  76. simtools/testing/sim_telarray_metadata.py +1 -1
  77. simtools/testing/validate_output.py +46 -15
  78. simtools/utils/names.py +2 -4
  79. simtools/utils/value_conversion.py +10 -5
  80. simtools/version.py +61 -0
  81. simtools/visualization/legend_handlers.py +14 -4
  82. simtools/visualization/plot_array_layout.py +229 -33
  83. simtools/visualization/plot_mirrors.py +837 -0
  84. simtools/visualization/plot_pixels.py +1 -1
  85. simtools/visualization/plot_psf.py +1 -1
  86. simtools/visualization/plot_tables.py +1 -1
  87. simtools/simtel/simtel_io_file_info.py +0 -62
  88. {gammasimtools-0.23.0.dist-info → gammasimtools-0.25.0.dist-info}/WHEEL +0 -0
  89. {gammasimtools-0.23.0.dist-info → gammasimtools-0.25.0.dist-info}/licenses/LICENSE +0 -0
  90. {gammasimtools-0.23.0.dist-info → gammasimtools-0.25.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,268 @@
1
+ """Pulse shape computations for light emission simulations for flasher."""
2
+
3
+ import numpy as np
4
+ from scipy.optimize import least_squares
5
+ from scipy.signal import fftconvolve
6
+
7
+
8
+ def _rise_width(t, y, y_low=0.1, y_high=0.9):
9
+ """Measure rise width between fractional amplitudes.
10
+
11
+ Parameters
12
+ ----------
13
+ t : array-like
14
+ Time samples in ns.
15
+ y : array-like
16
+ Pulse amplitude samples (normalized or arbitrary units).
17
+ y_low : float, optional
18
+ Lower fractional amplitude (0..1) on the rising edge. Default is 0.1.
19
+ y_high : float, optional
20
+ Upper fractional amplitude (0..1) on the rising edge. Default is 0.9.
21
+
22
+ Returns
23
+ -------
24
+ float
25
+ Width in ns between ``y_low`` and ``y_high`` on the rising edge.
26
+ """
27
+ i_peak = int(np.argmax(y))
28
+ tr = t[: i_peak + 1]
29
+ yr = y[: i_peak + 1]
30
+ t_low = np.interp(y_low, yr, tr)
31
+ t_high = np.interp(y_high, yr, tr)
32
+ return t_high - t_low
33
+
34
+
35
+ def _fall_width(t, y, y_high=0.9, y_low=0.1):
36
+ """Measure fall width between fractional amplitudes.
37
+
38
+ Parameters
39
+ ----------
40
+ t : array-like
41
+ Time samples in ns.
42
+ y : array-like
43
+ Pulse amplitude samples (normalized or arbitrary units).
44
+ y_high : float, optional
45
+ Higher fractional amplitude (0..1) on the falling edge. Default is 0.9.
46
+ y_low : float, optional
47
+ Lower fractional amplitude (0..1) on the falling edge. Default is 0.1.
48
+
49
+ Returns
50
+ -------
51
+ float
52
+ Width in ns between ``y_high`` and ``y_low`` on the falling edge.
53
+ """
54
+ i_peak = int(np.argmax(y))
55
+ tf = t[i_peak:]
56
+ yf = y[i_peak:]
57
+ t_rev = tf[::-1]
58
+ y_rev = yf[::-1]
59
+ t_hi = np.interp(y_high, y_rev, t_rev)
60
+ t_lo = np.interp(y_low, y_rev, t_rev)
61
+ return t_lo - t_hi
62
+
63
+
64
+ def _gaussian(t, sigma):
65
+ """Gaussian pulse shape.
66
+
67
+ Parameters
68
+ ----------
69
+ t : array-like
70
+ Time samples in ns.
71
+ sigma : float
72
+ Gaussian standard deviation in ns.
73
+
74
+ Returns
75
+ -------
76
+ numpy.ndarray
77
+ Gaussian values at ``t`` (unitless), with a small safeguard for ``sigma``.
78
+ """
79
+ return np.exp(-0.5 * (t / max(sigma, 1e-9)) ** 2)
80
+
81
+
82
+ def _exp_decay(t, tau):
83
+ """Causal exponential decay shape.
84
+
85
+ Parameters
86
+ ----------
87
+ t : array-like
88
+ Time samples in ns.
89
+ tau : float
90
+ Exponential decay constant in ns.
91
+
92
+ Returns
93
+ -------
94
+ numpy.ndarray
95
+ Exponential values at ``t`` (unitless), zero for ``t < 0``.
96
+ """
97
+ tau = max(tau, 1e-9)
98
+ return np.where(t >= 0, np.exp(-t / tau), 0.0)
99
+
100
+
101
+ def generate_gauss_expconv_pulse(
102
+ sigma_ns,
103
+ tau_ns,
104
+ dt_ns=0.1,
105
+ t_start_ns=-10,
106
+ t_stop_ns=25,
107
+ center_on_peak=False,
108
+ ):
109
+ """Generate a Gaussian convolved with a causal exponential.
110
+
111
+ Parameters
112
+ ----------
113
+ sigma_ns : float
114
+ Gaussian standard deviation (ns).
115
+ tau_ns : float
116
+ Exponential decay constant (ns).
117
+ dt_ns : float, optional
118
+ Time sampling step (ns). Default is 0.1 ns.
119
+ t_start_ns : float
120
+ Together with ``t_stop_ns``, defines the explicit start of the time grid
121
+ for pulse generation (ns).
122
+ t_stop_ns : float
123
+ Together with ``t_start_ns``, defines the explicit end of the time grid
124
+ for pulse generation (ns).
125
+ center_on_peak : bool, optional
126
+ If True, shift the returned time array so the pulse maximum is at t=0.
127
+ Default is False.
128
+
129
+ Returns
130
+ -------
131
+ tuple[numpy.ndarray, numpy.ndarray]
132
+ Tuple ``(t, y)`` with time samples in ns and normalized pulse amplitude (peak 1).
133
+ """
134
+ left = float(t_start_ns)
135
+ right = float(t_stop_ns)
136
+ t = np.arange(left, right, dt_ns, dtype=float)
137
+ g = _gaussian(t, sigma_ns)
138
+ e = _exp_decay(t, tau_ns)
139
+ y = fftconvolve(g, e, mode="same")
140
+ if y.max() > 0:
141
+ y = y / y.max()
142
+ if center_on_peak:
143
+ i_max = int(np.argmax(y))
144
+ t = t - float(t[i_max])
145
+ return t, y
146
+
147
+
148
+ def solve_sigma_tau_from_rise_fall(
149
+ rise_width_ns,
150
+ fall_width_ns,
151
+ dt_ns=0.1,
152
+ rise_range=(0.1, 0.9),
153
+ fall_range=(0.9, 0.1),
154
+ t_start_ns=-10,
155
+ t_stop_ns=25,
156
+ ):
157
+ """Solve (sigma, tau) so convolved pulse matches target rise/fall widths.
158
+
159
+ Parameters
160
+ ----------
161
+ rise_width_ns : float
162
+ Desired width on the rising edge in ns between rise_range=(low, high) fractions.
163
+ fall_width_ns : float
164
+ Desired width on the falling edge in ns between fall_range=(high, low) fractions.
165
+ dt_ns : float
166
+ Time step for internal pulse sampling in ns.
167
+ rise_range : tuple[float, float]
168
+ Fractional amplitudes (low, high) for the rising width, defaults to (0.1, 0.9).
169
+ fall_range : tuple[float, float]
170
+ Fractional amplitudes (high, low) for the falling width, defaults to (0.9, 0.1).
171
+ t_start_ns : float
172
+ Optional start time (ns) for the internal sampling window. If provided together with
173
+ ``t_stop_ns``, overrides the default window.
174
+ t_stop_ns : float
175
+ Optional stop time (ns) for the internal sampling window. If provided together with
176
+ ``t_start_ns``, overrides the default window.
177
+
178
+ Returns
179
+ -------
180
+ tuple[float, float]
181
+ Tuple ``(sigma_ns, tau_ns)`` giving the Gaussian sigma and exponential tau in ns.
182
+ """
183
+ t = np.arange(float(t_start_ns), float(t_stop_ns) + dt_ns, dt_ns, dtype=float)
184
+
185
+ def pulse(sigma, tau):
186
+ g = _gaussian(t, sigma)
187
+ e = _exp_decay(t, tau)
188
+ y = fftconvolve(g, e, mode="same")
189
+ return y / y.max() if y.max() > 0 else y
190
+
191
+ rise_lo, rise_hi = rise_range
192
+ fall_hi, fall_lo = fall_range
193
+
194
+ def residuals(x):
195
+ sigma, tau = x
196
+ y = pulse(sigma, tau)
197
+ r = _rise_width(t, y, y_low=rise_lo, y_high=rise_hi) - rise_width_ns
198
+ f = _fall_width(t, y, y_high=fall_hi, y_low=fall_lo) - fall_width_ns
199
+ return [r, f]
200
+
201
+ res = least_squares(residuals, x0=[0.3, 10.0], bounds=(1e-6, 500))
202
+ sigma, tau = float(res.x[0]), float(res.x[1])
203
+ return sigma, tau
204
+
205
+
206
+ def generate_pulse_from_rise_fall_times(
207
+ rise_width_ns,
208
+ fall_width_ns,
209
+ dt_ns=0.1,
210
+ rise_range=(0.1, 0.9),
211
+ fall_range=(0.9, 0.1),
212
+ t_start_ns=-10,
213
+ t_stop_ns=25,
214
+ center_on_peak=False,
215
+ ):
216
+ """Generate pulse from rise/fall time specifications.
217
+
218
+ Parameters
219
+ ----------
220
+ rise_width_ns : float
221
+ Target rise time (ns) between the fractional levels defined by ``rise_range``.
222
+ Defaults correspond to 10-90% rise time.
223
+ fall_width_ns : float
224
+ Target fall time (ns) between the fractional levels defined by ``fall_range``.
225
+ Defaults correspond to 90-10% fall time.
226
+ dt_ns : float, optional
227
+ Time sampling step (ns). Default is 0.1 ns.
228
+ rise_range : tuple[float, float], optional
229
+ Fractional amplitudes (low, high) for rise-time definition. Default (0.1, 0.9).
230
+ fall_range : tuple[float, float], optional
231
+ Fractional amplitudes (high, low) for fall-time definition. Default (0.9, 0.1).
232
+ t_start_ns : float, optional
233
+ Start time (ns) for the internal solver sampling window and output grid. Default -10.
234
+ t_stop_ns : float, optional
235
+ Stop time (ns) for the internal solver sampling window and output grid. Default 25.
236
+ center_on_peak : bool, optional
237
+ If True, shift the returned time array so the pulse maximum is at t=0.
238
+ Default is False.
239
+
240
+ Returns
241
+ -------
242
+ tuple[numpy.ndarray, numpy.ndarray]
243
+ Tuple ``(t, y)`` with time samples in ns and normalized pulse amplitude (peak 1).
244
+
245
+ Notes
246
+ -----
247
+ The model is a Gaussian convolved with an exponential. The parameters (sigma, tau)
248
+ are solved via least-squares such that the resulting pulse matches the requested rise and
249
+ fall times measured on monotonic segments relative to the peak.
250
+ """
251
+ sigma, tau = solve_sigma_tau_from_rise_fall(
252
+ rise_width_ns,
253
+ fall_width_ns,
254
+ dt_ns=dt_ns,
255
+ rise_range=rise_range,
256
+ fall_range=fall_range,
257
+ t_start_ns=t_start_ns,
258
+ t_stop_ns=t_stop_ns,
259
+ )
260
+
261
+ return generate_gauss_expconv_pulse(
262
+ sigma,
263
+ tau,
264
+ dt_ns=dt_ns,
265
+ t_start_ns=t_start_ns,
266
+ t_stop_ns=t_stop_ns,
267
+ center_on_peak=center_on_peak,
268
+ )
@@ -11,8 +11,11 @@ import numpy as np
11
11
  import simtools.utils.general as gen
12
12
  import simtools.version
13
13
  from simtools.io import ascii_handler
14
+ from simtools.simtel.pulse_shapes import generate_pulse_from_rise_fall_times
14
15
  from simtools.utils import names
15
16
 
17
+ logger = logging.getLogger(__name__)
18
+
16
19
 
17
20
  def sim_telarray_random_seeds(seed, number):
18
21
  """
@@ -121,6 +124,84 @@ class SimtelConfigWriter:
121
124
  ):
122
125
  file.write(f"{meta}\n")
123
126
 
127
+ @staticmethod
128
+ def write_lightpulse_table_gauss_expconv(
129
+ file_path,
130
+ width_ns=None,
131
+ exp_decay_ns=None,
132
+ dt_ns=0.1,
133
+ rise_range=(0.1, 0.9),
134
+ fall_range=(0.9, 0.1),
135
+ fadc_sum_bins=None,
136
+ time_margin_ns=10.0,
137
+ ):
138
+ """Write a pulse table for a Gaussian convolved with a causal exponential.
139
+
140
+ Parameters
141
+ ----------
142
+ file_path : str or pathlib.Path
143
+ Destination path of the ASCII pulse table to write. Parent directory must exist.
144
+ width_ns : float
145
+ Target rise time in ns between the fractional levels defined by ``rise_range``.
146
+ Defaults correspond to 10-90% rise time.
147
+ exp_decay_ns : float
148
+ Target fall time in ns between the fractional levels defined by ``fall_range``.
149
+ Defaults correspond to 90-10% fall time.
150
+ dt_ns : float, optional
151
+ Time sampling step in ns for the generated pulse table. Default is 0.1.
152
+ rise_range : tuple[float, float], optional
153
+ Fractional amplitude bounds (low, high) for rise-time definition. Default (0.1, 0.9).
154
+ fall_range : tuple[float, float], optional
155
+ Fractional amplitude bounds (high, low) for fall-time definition. Default (0.9, 0.1).
156
+ fadc_sum_bins : int
157
+ Length of the FADC integration window (treated as ns here) used to derive
158
+ the internal time sampling window of the solver as [-(margin), bins + margin].
159
+ time_margin_ns : float, optional
160
+ Margin in ns to add to both ends of the FADC window when ``fadc_sum_bins`` is given.
161
+ Default is 5.0 ns.
162
+
163
+ Returns
164
+ -------
165
+ pathlib.Path
166
+ The path to the created pulse table file.
167
+
168
+ Notes
169
+ -----
170
+ The underlying model is a Gaussian convolved with a causal exponential. The model
171
+ parameters (sigma, tau) are solved such that the normalized pulse matches the requested
172
+ rise and fall times. The pulse is normalized to a peak amplitude of 1.
173
+ """
174
+ if width_ns is None or exp_decay_ns is None:
175
+ raise ValueError("width_ns (rise 10-90) and exp_decay_ns (fall 90-10) are required")
176
+ logger.info(
177
+ f"Generating lightpulse table with rise10-90={width_ns} ns, "
178
+ f"fall90-10={exp_decay_ns} ns, dt={dt_ns} ns"
179
+ )
180
+ width = float(fadc_sum_bins)
181
+ t_start_ns = -abs(time_margin_ns + width)
182
+ t_stop_ns = +abs(time_margin_ns + width)
183
+ t, y = generate_pulse_from_rise_fall_times(
184
+ width_ns,
185
+ exp_decay_ns,
186
+ dt_ns=dt_ns,
187
+ rise_range=rise_range,
188
+ fall_range=fall_range,
189
+ t_start_ns=t_start_ns,
190
+ t_stop_ns=t_stop_ns,
191
+ center_on_peak=True,
192
+ )
193
+
194
+ return SimtelConfigWriter._write_ascii_pulse_table(file_path, t, y)
195
+
196
+ @staticmethod
197
+ def _write_ascii_pulse_table(file_path, t, y):
198
+ """Write two-column ASCII pulse table."""
199
+ with open(file_path, "w", encoding="utf-8") as fh:
200
+ fh.write("# time[ns] amplitude\n")
201
+ for ti, yi in zip(t, y):
202
+ fh.write(f"{ti:.6f} {yi:.8f}\n")
203
+ return Path(file_path)
204
+
124
205
  def _get_parameters_for_sim_telarray(self, parameters, config_file_path):
125
206
  """
126
207
  Convert parameter dictionary to sim_telarray configuration file format.
@@ -221,7 +302,7 @@ class SimtelConfigWriter:
221
302
  value = "none" if value is None else value # simtel requires 'none'
222
303
  if isinstance(value, bool):
223
304
  value = 1 if value else 0
224
- elif isinstance(value, (list, np.ndarray)):
305
+ elif isinstance(value, list | np.ndarray):
225
306
  value = gen.convert_list_to_string(value, shorten_list=True)
226
307
  return value
227
308
 
@@ -528,7 +609,7 @@ class SimtelConfigWriter:
528
609
  """
529
610
  file.write(self.TAB + "% Site parameters\n")
530
611
  for par, value in site_parameters.items():
531
- simtel_name, value = self._convert_model_parameters_to_simtel_format(
612
+ simtel_name, simtel_value = self._convert_model_parameters_to_simtel_format(
532
613
  names.get_simulation_software_name_from_parameter_name(
533
614
  par, software_name="sim_telarray"
534
615
  ),
@@ -537,7 +618,7 @@ class SimtelConfigWriter:
537
618
  telescope_model,
538
619
  )
539
620
  if simtel_name is not None:
540
- file.write(f"{self.TAB}{simtel_name} = {value}\n")
621
+ file.write(f"{self.TAB}{simtel_name} = {simtel_value}\n")
541
622
  for meta in self._get_sim_telarray_metadata(
542
623
  "site", site_parameters, None, additional_metadata
543
624
  ):
@@ -592,34 +673,111 @@ class SimtelConfigWriter:
592
673
  telescope_model: dict of TelescopeModel
593
674
  Telescope models.
594
675
  """
676
+ trigger_per_telescope_type = self._group_telescopes_by_type(telescope_model)
677
+ hardstereo_lines, non_hardstereo_groups, all_non_hardstereo_tels, multiplicity = (
678
+ self._process_telescope_triggers(array_triggers, trigger_per_telescope_type)
679
+ )
680
+
681
+ array_triggers_file = "array_triggers.dat"
682
+ with open(model_path / array_triggers_file, "w", encoding="utf-8") as file:
683
+ file.write("# Array trigger definition\n")
684
+ self._write_trigger_lines(
685
+ file, hardstereo_lines, non_hardstereo_groups, all_non_hardstereo_tels, multiplicity
686
+ )
687
+
688
+ return array_triggers_file
689
+
690
+ def _group_telescopes_by_type(self, telescope_model):
691
+ """Group telescopes by their type."""
595
692
  trigger_per_telescope_type = {}
596
693
  for count, tel_name in enumerate(telescope_model.keys()):
597
694
  telescope_type = names.get_array_element_type_from_name(tel_name)
598
695
  trigger_per_telescope_type.setdefault(telescope_type, []).append(count + 1)
696
+ return trigger_per_telescope_type
697
+
698
+ def _process_telescope_triggers(self, array_triggers, trigger_per_telescope_type):
699
+ """Process telescope triggers and group them by hardstereo and parameters."""
700
+ hardstereo_lines = []
701
+ non_hardstereo_groups = {}
702
+ all_non_hardstereo_tels = []
703
+ multiplicity = None
599
704
 
600
- trigger_lines = {}
601
705
  for tel_type, tel_list in trigger_per_telescope_type.items():
602
706
  trigger_dict = self._get_array_triggers_for_telescope_type(
603
707
  array_triggers, tel_type, len(tel_list)
604
708
  )
605
- trigger_lines[tel_type] = f"Trigger {trigger_dict['multiplicity']['value']} of "
606
- trigger_lines[tel_type] += ", ".join(map(str, tel_list))
607
- width = trigger_dict["width"]["value"] * u.Unit(trigger_dict["width"]["unit"]).to("ns")
608
- trigger_lines[tel_type] += f" width {width}"
609
- if trigger_dict.get("hard_stereo", {}).get("value"):
610
- trigger_lines[tel_type] += " hardstereo"
611
- if all(trigger_dict["min_separation"][key] is not None for key in ["value", "unit"]):
612
- min_sep = trigger_dict["min_separation"]["value"] * u.Unit(
613
- trigger_dict["min_separation"]["unit"]
614
- ).to("m")
615
- trigger_lines[tel_type] += f" minsep {min_sep}"
709
+ width, minsep = self._extract_trigger_parameters(trigger_dict)
710
+ multiplicity = trigger_dict["multiplicity"]["value"] # Store for later use
616
711
 
617
- array_triggers_file = "array_triggers.dat"
618
- with open(model_path / array_triggers_file, "w", encoding="utf-8") as file:
619
- file.write("# Array trigger definition\n")
620
- file.writelines(f"{line}\n" for line in trigger_lines.values())
621
-
622
- return array_triggers_file
712
+ if trigger_dict.get("hard_stereo", {}).get("value"):
713
+ line = self._build_trigger_line(
714
+ trigger_dict, tel_list, width, minsep, hardstereo=True
715
+ )
716
+ hardstereo_lines.append(line)
717
+ else:
718
+ key = (width, minsep)
719
+ non_hardstereo_groups.setdefault(key, []).extend(tel_list)
720
+ all_non_hardstereo_tels.extend(tel_list)
721
+
722
+ return hardstereo_lines, non_hardstereo_groups, all_non_hardstereo_tels, multiplicity
723
+
724
+ def _extract_trigger_parameters(self, trigger_dict):
725
+ """Extract width and min_separation parameters from trigger dictionary."""
726
+ width = trigger_dict["width"]["value"] * u.Unit(trigger_dict["width"]["unit"]).to("ns")
727
+ minsep = None
728
+ if all(trigger_dict["min_separation"][key] is not None for key in ["value", "unit"]):
729
+ minsep = trigger_dict["min_separation"]["value"] * u.Unit(
730
+ trigger_dict["min_separation"]["unit"]
731
+ ).to("m")
732
+ return width, minsep
733
+
734
+ def _build_trigger_line(self, trigger_dict, tel_list, width, minsep, hardstereo=False):
735
+ """Build a trigger line string."""
736
+ line = f"Trigger {trigger_dict['multiplicity']['value']} of "
737
+ line += ", ".join(map(str, tel_list))
738
+ line += f" width {width}"
739
+ if hardstereo:
740
+ line += " hardstereo"
741
+ if minsep is not None:
742
+ line += f" minsep {minsep}"
743
+ return line
744
+
745
+ def _write_trigger_lines(
746
+ self, file, hardstereo_lines, non_hardstereo_groups, all_non_hardstereo_tels, multiplicity
747
+ ):
748
+ """Write all trigger lines to file."""
749
+ # Write hardstereo lines first
750
+ for line in hardstereo_lines:
751
+ file.write(f"{line}\n")
752
+
753
+ # Write individual non-hardstereo groups if they have different parameters
754
+ if len(non_hardstereo_groups) > 1:
755
+ for (width, minsep), tel_list in non_hardstereo_groups.items():
756
+ line = f"Trigger {multiplicity} of "
757
+ line += ", ".join(map(str, tel_list))
758
+ line += f" width {width}"
759
+ if minsep is not None:
760
+ line += f" minsep {minsep}"
761
+ file.write(f"{line}\n")
762
+
763
+ # Write combined line with all non-hardstereo telescopes using shortest values
764
+ if all_non_hardstereo_tels:
765
+ min_width = min(width for width, minsep in non_hardstereo_groups.keys())
766
+ min_minsep = self._get_minimum_minsep(non_hardstereo_groups)
767
+
768
+ combined_line = f"Trigger {multiplicity} of "
769
+ combined_line += ", ".join(map(str, sorted(all_non_hardstereo_tels)))
770
+ combined_line += f" width {min_width}"
771
+ if min_minsep is not None:
772
+ combined_line += f" minsep {min_minsep}"
773
+ file.write(f"{combined_line}\n")
774
+
775
+ def _get_minimum_minsep(self, non_hardstereo_groups):
776
+ """Get minimum min_separation value from groups."""
777
+ minsep_values = [
778
+ minsep for width, minsep in non_hardstereo_groups.keys() if minsep is not None
779
+ ]
780
+ return min(minsep_values) if minsep_values else None
623
781
 
624
782
  def _get_array_triggers_for_telescope_type(
625
783
  self, array_triggers, telescope_type, num_telescopes_of_type
@@ -17,7 +17,7 @@ from eventio.simtel import (
17
17
  )
18
18
 
19
19
  from simtools.corsika.primary_particle import PrimaryParticle
20
- from simtools.simtel.simtel_io_file_info import get_corsika_run_header
20
+ from simtools.io.eventio_handler import get_combined_corsika_run_header
21
21
  from simtools.simtel.simtel_io_metadata import (
22
22
  get_sim_telarray_telescope_id_to_telescope_name_mapping,
23
23
  read_sim_telarray_metadata,
@@ -158,7 +158,7 @@ class SimtelIOEventDataWriter:
158
158
 
159
159
  def _process_file_info(self, file_id, file):
160
160
  """Process file information and append to file info list."""
161
- run_info = get_corsika_run_header(file)
161
+ run_info = get_combined_corsika_run_header(file)
162
162
  self.telescope_id_to_name = get_sim_telarray_telescope_id_to_telescope_name_mapping(file)
163
163
  particle = PrimaryParticle(
164
164
  particle_id_type="eventio_id", particle_id=run_info.get("primary_id", 1)
@@ -1,6 +1,7 @@
1
1
  """Simulation runner for array simulations."""
2
2
 
3
3
  import logging
4
+ import stat
4
5
 
5
6
  from simtools.io import io_handler
6
7
  from simtools.runners.simtel_runner import InvalidOutputFileError, SimtelRunner
@@ -42,6 +43,7 @@ class SimulatorArray(SimtelRunner):
42
43
  simtel_path=simtel_path,
43
44
  corsika_config=corsika_config,
44
45
  use_multipipe=use_multipipe,
46
+ calibration_run_mode=calibration_config.get("run_mode") if calibration_config else None,
45
47
  )
46
48
 
47
49
  self.sim_telarray_seeds = sim_telarray_seeds
@@ -50,6 +52,52 @@ class SimulatorArray(SimtelRunner):
50
52
  self.io_handler = io_handler.IOHandler()
51
53
  self._log_file = None
52
54
 
55
+ def prepare_run_script(self, test=False, input_file=None, run_number=None, extra_commands=None):
56
+ """
57
+ Build and return the full path of the bash run script containing the sim_telarray command.
58
+
59
+ Parameters
60
+ ----------
61
+ test: bool
62
+ Test flag for faster execution.
63
+ input_file: str or Path
64
+ Full path of the input CORSIKA file.
65
+ run_number: int
66
+ Run number.
67
+ extra_commands: list[str]
68
+ Additional commands for running simulations given in config.yml.
69
+
70
+ Returns
71
+ -------
72
+ Path
73
+ Full path of the run script.
74
+ """
75
+ script_file_path = self.get_file_name(file_type="sub_script", run_number=run_number)
76
+ self._logger.debug(f"Run bash script - {script_file_path}")
77
+ self._logger.debug(f"Extra commands to be added to the run script {extra_commands}")
78
+
79
+ command = self.make_run_command(run_number=run_number, input_file=input_file)
80
+ with script_file_path.open("w", encoding="utf-8") as file:
81
+ file.write("#!/usr/bin/env bash\n\n")
82
+ file.write("set -e\n")
83
+ file.write("set -o pipefail\n")
84
+ file.write("\nSECONDS=0\n")
85
+
86
+ if extra_commands:
87
+ file.write("# Writing extras\n")
88
+ for line in extra_commands:
89
+ file.write(f"{line}\n")
90
+ file.write("# End of extras\n\n")
91
+
92
+ n = 1 if test else self.runs_per_set
93
+ for _ in range(n):
94
+ file.write(f"{command}\n\n")
95
+
96
+ file.write('\necho "RUNTIME: $SECONDS"\n')
97
+
98
+ script_file_path.chmod(script_file_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
99
+ return script_file_path
100
+
53
101
  def make_run_command(self, run_number=None, input_file=None, weak_pointing=None):
54
102
  """
55
103
  Build and return the command to run sim_telarray.
@@ -111,12 +159,12 @@ class SimulatorArray(SimtelRunner):
111
159
  command += super().get_config_option(key, cfg[key])
112
160
 
113
161
  run_mode = cfg.get("run_mode")
114
- if run_mode in ("pedestals", "nsb_only_pedestals"):
162
+ if run_mode in ("pedestals", "pedestals_nsb_only"):
115
163
  n_events = cfg.get("number_of_pedestal_events", cfg["number_of_events"])
116
164
  command += super().get_config_option("pedestal_events", n_events)
117
- if run_mode == "nsb_only_pedestals":
118
- command += self._nsb_only_pedestals_command()
119
- if run_mode == "dark_pedestals":
165
+ if run_mode == "pedestals_nsb_only":
166
+ command += self._pedestals_nsb_only_command()
167
+ if run_mode == "pedestals_dark":
120
168
  n_events = cfg.get("number_of_dark_events", cfg["number_of_events"])
121
169
  command += super().get_config_option("dark_events", n_events)
122
170
  if run_mode == "direct_injection":
@@ -155,7 +203,7 @@ class SimulatorArray(SimtelRunner):
155
203
 
156
204
  return command
157
205
 
158
- def _nsb_only_pedestals_command(self):
206
+ def _pedestals_nsb_only_command(self):
159
207
  """
160
208
  Generate the command to run sim_telarray for nsb-only pedestal simulations.
161
209
 
@@ -193,7 +241,7 @@ class SimulatorArray(SimtelRunner):
193
241
 
194
242
  def _check_run_result(self, run_number=None):
195
243
  """
196
- Check if simtel output file exists.
244
+ Check if sim_telarray output file exists.
197
245
 
198
246
  Parameters
199
247
  ----------
@@ -203,18 +251,16 @@ class SimulatorArray(SimtelRunner):
203
251
  Returns
204
252
  -------
205
253
  bool
206
- True if simtel output file exists.
254
+ True if sim_telarray output file exists.
207
255
 
208
256
  Raises
209
257
  ------
210
258
  InvalidOutputFileError
211
- If simtel output file does not exist.
259
+ If sim_telarray output file does not exist.
212
260
  """
213
261
  output_file = self.get_file_name(file_type="simtel_output", run_number=run_number)
214
262
  if not output_file.exists():
215
- msg = f"sim_telarray output file {output_file} does not exist."
216
- self._logger.error(msg)
217
- raise InvalidOutputFileError(msg)
263
+ raise InvalidOutputFileError(f"sim_telarray output file {output_file} does not exist.")
218
264
  self._logger.debug(f"sim_telarray output file {output_file} exists.")
219
265
  return True
220
266
 
@@ -223,7 +269,7 @@ class SimulatorArray(SimtelRunner):
223
269
  """
224
270
  Get the power law index for sim_telarray.
225
271
 
226
- Events will be histogrammed in sim_telarray with a weight according to
272
+ Events will be filled in histograms in sim_telarray with a weight according to
227
273
  the difference between this exponent and the one used for the shower simulations.
228
274
 
229
275
  Parameters