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
@@ -1,8 +1,6 @@
1
1
  """Extract Cherenkov photons from a CORSIKA IACT file and fill histograms."""
2
2
 
3
- import functools
4
3
  import logging
5
- import operator
6
4
  from pathlib import Path
7
5
 
8
6
  import boost_histogram as bh
@@ -21,6 +19,9 @@ class CorsikaHistograms:
21
19
  ----------
22
20
  input_file: str or Path
23
21
  CORSIKA IACT file.
22
+ axis_distance: astropy.units.Quantity or float
23
+ Distance from the axis to consider when calculating the lateral density profiles
24
+ along x and y axes. If a float is given, it is assumed to be in meters.
24
25
 
25
26
  Raises
26
27
  ------
@@ -28,30 +29,23 @@ class CorsikaHistograms:
28
29
  if the input file given does not exist.
29
30
  """
30
31
 
31
- def __init__(self, input_file):
32
+ def __init__(self, input_file, normalization_method="per-telescope", axis_distance=1000 * u.m):
32
33
  self._logger = logging.getLogger(__name__)
33
- self._logger.debug("Init CorsikaHistograms")
34
34
  self.input_file = Path(input_file)
35
35
  if not self.input_file.exists():
36
36
  raise FileNotFoundError(f"File {self.input_file} does not exist.")
37
37
 
38
+ self.axis_distance = (
39
+ axis_distance.to(u.m).value if isinstance(axis_distance, u.Quantity) else axis_distance
40
+ )
38
41
  self.events = None
39
42
  self.hist = self._set_2d_distributions()
40
43
  self.hist.update(self._set_1d_distributions())
44
+ self._density_samples = []
45
+ self.normalization_method = normalization_method
41
46
 
42
47
  def fill(self):
43
- """
44
- Fill Cherenkov photons histograms.
45
-
46
- Returns
47
- -------
48
- list: list of boost_histogram.Histogram instances.
49
-
50
- Raises
51
- ------
52
- AttributeError:
53
- if event has not photon saved.
54
- """
48
+ """Fill Cherenkov photons histograms."""
55
49
  self._read_event_headers()
56
50
 
57
51
  with IACTFile(self.input_file) as f:
@@ -59,7 +53,7 @@ class CorsikaHistograms:
59
53
  for event_counter, event in enumerate(f):
60
54
  if hasattr(event, "photon_bunches"):
61
55
  photons = list(event.photon_bunches.values())
62
- self._fill_histograms(photons, event_counter, telescope_positions, True)
56
+ self._fill_histograms(photons, event_counter, telescope_positions, False)
63
57
 
64
58
  self._update_distributions()
65
59
 
@@ -155,7 +149,10 @@ class CorsikaHistograms:
155
149
  incoming direction of the primary particle.
156
150
  """
157
151
  hist_str = "histogram"
158
- for photon, telescope in zip(photons, telescope_positions):
152
+ photons_per_telescope = np.zeros(len(telescope_positions))
153
+ zenith_rad = np.deg2rad(self.events["zenith_deg"][event_counter])
154
+
155
+ for tel_idx, (photon, telescope) in enumerate(zip(photons, telescope_positions)):
159
156
  if rotate_photons:
160
157
  px, py = rotate(
161
158
  photon["x"],
@@ -166,16 +163,16 @@ class CorsikaHistograms:
166
163
  else:
167
164
  px, py = photon["x"], photon["y"]
168
165
 
169
- px -= telescope["x"]
170
- py -= telescope["y"]
166
+ px = px - telescope["x"]
167
+ py = py - telescope["y"]
171
168
  w = photon["photons"]
172
169
 
173
170
  pxm = px * u.cm.to(u.m)
174
171
  pym = py * u.cm.to(u.m)
175
172
  zem = (photon["zem"] * u.cm).to(u.km)
173
+ photons_per_telescope[tel_idx] += np.sum(w)
176
174
 
177
175
  self.hist["counts_xy"][hist_str].fill(pxm, pym, weight=w)
178
- self.hist["density_xy"][hist_str].fill(pxm, pym, weight=w)
179
176
  self.hist["direction_xy"][hist_str].fill(photon["cx"], photon["cy"], weight=w)
180
177
  self.hist["time_altitude"][hist_str].fill(photon["time"] * u.ns, zem, weight=w)
181
178
  self.hist["wavelength_altitude"][hist_str].fill(
@@ -184,10 +181,23 @@ class CorsikaHistograms:
184
181
 
185
182
  r = np.hypot(px, py) * u.cm.to(u.m)
186
183
  self.hist["counts_r"][hist_str].fill(r, weight=w)
187
- self.hist["density_r"][hist_str].fill(r, weight=w)
188
184
 
189
185
  self.events["num_photons"][event_counter] += np.sum(w)
190
186
 
187
+ for tel_idx, telescope in enumerate(telescope_positions):
188
+ area = np.pi * (telescope["r"] ** 2) / np.cos(zenith_rad) / 1.0e4 # in m^2
189
+ n_photons = photons_per_telescope[tel_idx]
190
+ density = n_photons / area if area > 0 else 0.0
191
+ density_error = np.sqrt(n_photons) / area if area > 0 else 0.0
192
+ self._density_samples.append(
193
+ {
194
+ "x": telescope["x"] * u.cm.to(u.m),
195
+ "y": telescope["y"] * u.cm.to(u.m),
196
+ "density": density,
197
+ "density_error": density_error,
198
+ }
199
+ )
200
+
191
201
  def get_hist_2d_projection(self, hist):
192
202
  """
193
203
  Get 2D distributions.
@@ -318,6 +328,9 @@ class CorsikaHistograms:
318
328
  x_axis_title = "x_axis_title"
319
329
  y_axis_unit = "y_axis_unit"
320
330
  y_axis_title = "y_axis_title"
331
+ log_y = "log_y"
332
+ photon_density = "Photon density"
333
+ distance_to_center = "Distance to center"
321
334
  hist_1d = {
322
335
  "wavelength": {
323
336
  file_name: "hist_1d_photon_wavelength_distr",
@@ -328,27 +341,42 @@ class CorsikaHistograms:
328
341
  file_name: "hist_1d_photon_radial_distr",
329
342
  title: "Photon lateral distribution (ground level)",
330
343
  x_bins: [bins, 0 * u.m, r_max, "linear"],
331
- x_axis_title: "Distance to center",
344
+ x_axis_title: distance_to_center,
332
345
  x_axis_unit: u.m,
333
346
  },
334
347
  "density_r": {
335
348
  file_name: "hist_1d_photon_density_distr",
336
349
  title: "Photon lateral density distribution (ground level)",
337
350
  x_bins: [bins, 0 * u.m, r_max, "linear"],
338
- x_axis_title: "Distance to center",
351
+ x_axis_title: distance_to_center,
352
+ x_axis_unit: u.m,
353
+ y_axis_title: photon_density,
354
+ y_axis_unit: u.m**-2,
355
+ },
356
+ "density_r_from_counts": {
357
+ file_name: "hist_1d_photon_density_from_counts_distr",
358
+ title: "Photon lateral density from counts distribution (ground level)",
359
+ x_bins: [bins, 0 * u.m, r_max, "linear"],
360
+ x_axis_title: distance_to_center,
339
361
  x_axis_unit: u.m,
340
- y_axis_title: "Photon density",
362
+ y_axis_title: photon_density,
341
363
  y_axis_unit: u.m**-2,
342
364
  },
343
365
  "density_x": {
344
366
  file_name: "hist_1d_photon_density_x_distr",
345
367
  title: "Photon lateral density x distribution (ground level)",
346
- projection: ["density_xy", "x"],
368
+ projection: ["counts_xy", "x"], # projection requires counts_xy histogram
369
+ x_axis_title: distance_to_center,
370
+ x_axis_unit: u.m,
371
+ y_axis_title: photon_density,
372
+ y_axis_unit: u.m**-2,
347
373
  },
348
374
  "density_y": {
349
375
  file_name: "hist_1d_photon_density_y_distr",
350
376
  title: "Photon lateral density y distribution (ground level)",
351
- projection: ["density_xy", "y"],
377
+ projection: ["counts_xy", "y"], # projection requires counts_xy histogram
378
+ y_axis_title: photon_density,
379
+ y_axis_unit: u.m**-2,
352
380
  },
353
381
  "time": {
354
382
  file_name: "hist_1d_photon_time_distr",
@@ -377,12 +405,13 @@ class CorsikaHistograms:
377
405
  x_bins: [100, 0, None, "log"],
378
406
  x_axis_title: "Cherenkov photons per event",
379
407
  x_axis_unit: u.dimensionless_unscaled,
408
+ log_y: False,
380
409
  },
381
410
  }
382
411
 
383
412
  for value in hist_1d.values():
384
413
  value["is_1d"] = True
385
- value["log_y"] = True
414
+ value["log_y"] = value.get("log_y", True)
386
415
  value[y_axis_title] = (
387
416
  "Counts" if value.get(y_axis_title) is None else value[y_axis_title]
388
417
  )
@@ -419,6 +448,9 @@ class CorsikaHistograms:
419
448
  x_axis_title, x_axis_unit = "x_axis_title", "x_axis_unit"
420
449
  y_axis_title, y_axis_unit = "y_axis_title", "y_axis_unit"
421
450
  z_axis_title, z_axis_unit = "z_axis_title", "z_axis_unit"
451
+ photon_density = "Photon density"
452
+ x_pos = "x position on the ground"
453
+ y_pos = "y position on the ground"
422
454
 
423
455
  hist_2d = {
424
456
  "counts_xy": {
@@ -426,21 +458,33 @@ class CorsikaHistograms:
426
458
  title: "Photon count distribution (ground level)",
427
459
  x_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"],
428
460
  y_bins: [xy_bin, -xy_maximum, xy_maximum],
429
- x_axis_title: "x position on the ground",
461
+ x_axis_title: x_pos,
430
462
  x_axis_unit: xy_maximum.unit,
431
- y_axis_title: "y position on the ground",
463
+ y_axis_title: y_pos,
432
464
  y_axis_unit: xy_maximum.unit,
433
465
  },
434
466
  "density_xy": {
435
467
  file_name: "hist_2d_photon_density_distr",
436
- title: "Photon lateral density distribution (ground level)",
468
+ title: "Photon density distribution (ground level)",
469
+ x_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"],
470
+ y_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"],
471
+ x_axis_title: x_pos,
472
+ x_axis_unit: xy_maximum.unit,
473
+ y_axis_title: y_pos,
474
+ y_axis_unit: xy_maximum.unit,
475
+ z_axis_title: photon_density,
476
+ z_axis_unit: u.m**-2,
477
+ },
478
+ "density_xy_from_counts": {
479
+ file_name: "hist_2d_photon_density_from_counts_distr",
480
+ title: "Photon density from counts distribution (ground level)",
437
481
  x_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"],
438
482
  y_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"],
439
- x_axis_title: "x position on the ground",
483
+ x_axis_title: x_pos,
440
484
  x_axis_unit: xy_maximum.unit,
441
- y_axis_title: "y position on the ground",
485
+ y_axis_title: y_pos,
442
486
  y_axis_unit: xy_maximum.unit,
443
- z_axis_title: "Photon density",
487
+ z_axis_title: photon_density,
444
488
  z_axis_unit: u.m**-2,
445
489
  },
446
490
  "direction_xy": {
@@ -493,50 +537,183 @@ class CorsikaHistograms:
493
537
 
494
538
  def _update_distributions(self):
495
539
  """Update the distributions dictionary with the histogram values and bin edges."""
496
- self._normalize_density_histograms()
540
+ self._populate_density_from_probes()
541
+ self._populate_density_from_counts()
542
+ self._filter_density_histograms()
497
543
 
498
544
  for key, value in self.hist.items():
499
545
  value["input_file_name"] = str(self.input_file)
500
- if value["is_1d"]:
501
- value["hist_values"], value["x_bin_edges"], value["uncertainties"] = (
502
- self.get_hist_1d_projection(key, value)
503
- )
504
- else:
505
- (
506
- value["hist_values"],
507
- value["x_bin_edges"],
508
- value["y_bin_edges"],
509
- value["uncertainties"],
510
- ) = self.get_hist_2d_projection(value["histogram"])
546
+ if "hist_values" not in value:
547
+ if value["is_1d"]:
548
+ (
549
+ value["hist_values"],
550
+ value["x_bin_edges"],
551
+ value["uncertainties"],
552
+ ) = self.get_hist_1d_projection(key, value)
553
+ else:
554
+ (
555
+ value["hist_values"],
556
+ value["x_bin_edges"],
557
+ value["y_bin_edges"],
558
+ value["uncertainties"],
559
+ ) = self.get_hist_2d_projection(value["histogram"])
560
+
561
+ def _filter_density_histograms(self):
562
+ """Filter density histograms based on the normalization method."""
563
+ if self.normalization_method == "per-telescope":
564
+ keys_to_remove = ["density_xy_from_counts", "density_r_from_counts"]
565
+ elif self.normalization_method == "per-bin":
566
+ keys_to_remove = ["density_xy", "density_x", "density_y", "density_r"]
567
+ else:
568
+ raise ValueError(
569
+ f"Unknown normalization_method: {self.normalization_method}. "
570
+ "Must be 'per-telescope' or 'per-bin'."
571
+ )
511
572
 
512
- def _normalize_density_histograms(self):
513
- """Normalize the density histograms by the area of each bin."""
573
+ for key in keys_to_remove:
574
+ if key in self.hist:
575
+ del self.hist[key]
514
576
 
515
- def normalize_histogram(hist, bin_areas):
516
- view = hist.view()
517
- if self._check_for_all_attributes(view):
518
- view["value"] /= bin_areas
519
- view["variance"] /= bin_areas**2
520
- else:
521
- hist /= bin_areas
577
+ def _fill_projected_density_values(self, value):
578
+ """Extract 1D density by using projection Counts and normalizing by area."""
579
+ histo_2d = value["projection"][0]
580
+ source_h = self.hist[histo_2d]["histogram"]
581
+ project_axis = value["projection"][1]
522
582
 
523
- def normalize_histogram_1d(hist, bin_areas):
524
- view = hist.view()
525
- if self._check_for_all_attributes(view):
526
- view["value"] /= bin_areas
527
- view["variance"] /= bin_areas**2
528
- else:
529
- view /= bin_areas
583
+ if project_axis == "x":
584
+ h_1d = source_h[:, sum]
585
+ total_ortho_width = source_h.axes[1].edges[-1] - source_h.axes[1].edges[0]
586
+ else:
587
+ h_1d = source_h[sum, :]
588
+ total_ortho_width = source_h.axes[0].edges[-1] - source_h.axes[0].edges[0]
589
+
590
+ areas_1d = h_1d.axes[0].widths * total_ortho_width
591
+
592
+ view = h_1d.view()
593
+ if self._check_for_all_attributes(view):
594
+ vals = view["value"] / areas_1d
595
+ uncs = np.sqrt(view["variance"]) / areas_1d
596
+ else:
597
+ vals = view / areas_1d
598
+ uncs = np.sqrt(vals) # Fallback if no weights
599
+
600
+ value["hist_values"] = np.asarray([vals.T])
601
+ value["x_bin_edges"] = np.asarray([h_1d.axes.edges[0]])
602
+ value["uncertainties"] = np.asarray([uncs.T])
603
+
604
+ def _populate_density_from_probes(self):
605
+ """Build density distributions from per-telescope sampling."""
606
+ if not self._density_samples:
607
+ return
608
+
609
+ s = self._density_samples
610
+ xs, ys = (np.array([p[k] for p in s]) for k in ("x", "y"))
611
+ dens = np.array([p["density"] for p in s])
612
+ errs = np.array([p["density_error"] for p in s])
613
+
614
+ hxy = self.hist["counts_xy"]["histogram"]
615
+ x_edges, y_edges = hxy.axes[0].edges, hxy.axes[1].edges
616
+ r_edges = self.hist["density_r"]["histogram"].axes[0].edges
617
+
618
+ def avg_unc_nd(coords, edges, values, errors):
619
+ num = np.histogramdd(coords, bins=edges, weights=values)[0]
620
+ den = np.histogramdd(coords, bins=edges)[0]
621
+ var = np.histogramdd(coords, bins=edges, weights=errors**2)[0]
622
+ with np.errstate(divide="ignore", invalid="ignore"):
623
+ avg = np.divide(num, den, out=np.zeros_like(num), where=den > 0)
624
+ unc = np.sqrt(np.divide(var, den**2, out=np.zeros_like(var), where=den > 0))
625
+ return avg, unc
626
+
627
+ # 2D
628
+ avg_xy, unc_xy = avg_unc_nd(np.column_stack((xs, ys)), (x_edges, y_edges), dens, errs)
629
+
630
+ self.hist["density_xy"].update(
631
+ {
632
+ "hist_values": np.asarray([avg_xy.T]),
633
+ "x_bin_edges": np.asarray([x_edges]),
634
+ "y_bin_edges": np.asarray([y_edges]),
635
+ "uncertainties": np.asarray([unc_xy.T]),
636
+ }
637
+ )
638
+
639
+ # 1D helpers
640
+ def avg_unc_1d(x, e, v, err):
641
+ return avg_unc_nd(x[:, None], (e,), v, err)
642
+
643
+ ax = self.axis_distance
530
644
 
531
- density_xy_hist = self.hist["density_xy"]["histogram"]
532
- density_r_hist = self.hist["density_r"]["histogram"]
645
+ avg_x, unc_x = (
646
+ avg_unc_1d(xs[np.abs(ys) < ax], x_edges, dens[np.abs(ys) < ax], errs[np.abs(ys) < ax])
647
+ if np.any(np.abs(ys) < ax)
648
+ else (np.zeros(len(x_edges) - 1),) * 2
649
+ )
650
+
651
+ avg_y, unc_y = (
652
+ avg_unc_1d(ys[np.abs(xs) < ax], y_edges, dens[np.abs(xs) < ax], errs[np.abs(xs) < ax])
653
+ if np.any(np.abs(xs) < ax)
654
+ else (np.zeros(len(y_edges) - 1),) * 2
655
+ )
656
+
657
+ # Radial density
658
+ rs = np.hypot(xs, ys)
659
+ avg_r, unc_r = avg_unc_1d(rs, r_edges, dens, errs)
660
+
661
+ for k, avg, unc, edges in (
662
+ ("density_x", avg_x, unc_x, x_edges),
663
+ ("density_y", avg_y, unc_y, y_edges),
664
+ ("density_r", avg_r, unc_r, r_edges),
665
+ ):
666
+ self.hist[k].update(
667
+ {
668
+ "hist_values": np.asarray([avg]),
669
+ "x_bin_edges": np.asarray([edges]),
670
+ "uncertainties": np.asarray([unc]),
671
+ }
672
+ )
533
673
 
534
- bin_areas_xy = functools.reduce(operator.mul, density_xy_hist.axes.widths)
535
- normalize_histogram(density_xy_hist, bin_areas_xy)
674
+ def _populate_density_from_counts(self):
675
+ """Build density distributions by dividing counts histograms by bin area."""
676
+ # --- 2D ---
677
+ hxy = self.hist["counts_xy"]["histogram"]
678
+ xw = np.diff(hxy.axes[0].edges)
679
+ yw = np.diff(hxy.axes[1].edges)
680
+ areas2d = np.outer(xw, yw)
681
+
682
+ dens_xy, unc_xy = self._density_and_unc(hxy.view(), areas2d)
683
+
684
+ self.hist["density_xy_from_counts"].update(
685
+ {
686
+ "hist_values": np.asarray([dens_xy.T]),
687
+ "x_bin_edges": np.asarray([hxy.axes[0].edges]),
688
+ "y_bin_edges": np.asarray([hxy.axes[1].edges]),
689
+ "uncertainties": np.asarray([unc_xy.T]),
690
+ }
691
+ )
692
+
693
+ # --- 1D ---
694
+ hr = self.hist["counts_r"]["histogram"]
695
+ r = hr.axes[0].edges
696
+ areas1d = np.pi * (r[1:] ** 2 - r[:-1] ** 2)
536
697
 
537
- bin_edges_r = density_r_hist.axes.edges[0]
538
- bin_areas_r = np.pi * (bin_edges_r[1:] ** 2 - bin_edges_r[:-1] ** 2)
539
- normalize_histogram_1d(density_r_hist, bin_areas_r)
698
+ dens_r, unc_r = self._density_and_unc(hr.view(), areas1d)
699
+
700
+ self.hist["density_r_from_counts"].update(
701
+ {
702
+ "hist_values": np.asarray([dens_r]),
703
+ "x_bin_edges": np.asarray([r]),
704
+ "uncertainties": np.asarray([unc_r]),
705
+ }
706
+ )
707
+
708
+ def _density_and_unc(self, view, areas):
709
+ """Calculate density and uncertainty by dividing histogram values by areas."""
710
+ if self._check_for_all_attributes(view):
711
+ values = view["value"]
712
+ unc = np.sqrt(view["variance"])
713
+ else:
714
+ values = view
715
+ unc = np.sqrt(view)
716
+ return values / areas, unc / areas
540
717
 
541
718
  def _check_for_all_attributes(self, view):
542
719
  """Check if view has dtype fields ('value', 'variance')."""
@@ -7,6 +7,7 @@ import packaging.version
7
7
  from astropy.io.registry.base import IORegistryError
8
8
 
9
9
  import simtools.utils.general as gen
10
+ from simtools import settings
10
11
  from simtools.data_model import schema, validate_data
11
12
  from simtools.data_model.metadata_collector import MetadataCollector
12
13
  from simtools.db import db_handler
@@ -20,69 +21,62 @@ class ModelDataWriter:
20
21
 
21
22
  Parameters
22
23
  ----------
23
- product_data_file: str
24
+ output_file: str
24
25
  Name of output file.
25
- product_data_format: str
26
+ output_file_format: str
26
27
  Format of output file.
27
- args_dict: Dictionary
28
- Dictionary with configuration parameters.
29
28
  output_path: str or Path
30
29
  Path to output file.
31
- args_dict: dict
32
- Dictionary with configuration parameters.
33
-
34
30
  """
35
31
 
36
- def __init__(
37
- self,
38
- product_data_file=None,
39
- product_data_format=None,
40
- output_path=None,
41
- args_dict=None,
42
- ):
32
+ def __init__(self, output_file=None, output_file_format=None, output_path=None):
43
33
  """Initialize model data writer."""
44
34
  self._logger = logging.getLogger(__name__)
45
35
  self.io_handler = io_handler.IOHandler()
46
36
  self.schema_dict = {}
47
- if args_dict is not None:
48
- output_path = args_dict.get("output_path", output_path)
49
- if output_path is not None:
50
- self.io_handler.set_paths(output_path=output_path)
37
+ self.output_label = "model_data_writer"
38
+ self.io_handler.set_paths(
39
+ output_path=output_path or settings.config.args.get("output_path"),
40
+ output_path_label=self.output_label,
41
+ )
51
42
  try:
52
- self.product_data_file = self.io_handler.get_output_file(file_name=product_data_file)
43
+ self.output_file = self.io_handler.get_output_file(
44
+ file_name=output_file, output_path_label=self.output_label
45
+ )
53
46
  except TypeError:
54
- self.product_data_file = None
55
- self.product_data_format = self._astropy_data_format(product_data_format)
47
+ self.output_file = None
48
+ self.output_file_format = self._derive_data_format(output_file_format, self.output_file)
56
49
 
57
50
  @staticmethod
58
51
  def dump(
59
- args_dict, output_file=None, metadata=None, product_data=None, validate_schema_file=None
52
+ output_file=None,
53
+ metadata=None,
54
+ product_data=None,
55
+ output_file_format="ascii.ecsv",
56
+ validate_schema_file=None,
60
57
  ):
61
58
  """
62
59
  Write model data and metadata (as static method).
63
60
 
64
61
  Parameters
65
62
  ----------
66
- args_dict: dict
67
- Dictionary with configuration parameters (including output file name and path).
68
63
  output_file: string or Path
69
64
  Name of output file (args["output_file"] is used if this parameter is not set).
70
65
  metadata: MetadataCollector object
71
66
  Metadata to be written.
72
67
  product_data: astropy Table
73
68
  Model data to be written
69
+ output_file_format: str
70
+ Format of output file.
74
71
  validate_schema_file: str
75
72
  Schema file used in validation of output data.
76
-
77
73
  """
78
74
  writer = ModelDataWriter(
79
- product_data_file=(
80
- args_dict.get("output_file", None) if output_file is None else output_file
81
- ),
82
- product_data_format=args_dict.get("output_file_format", "ascii.ecsv"),
83
- args_dict=args_dict,
75
+ output_file=output_file,
76
+ output_file_format=output_file_format,
84
77
  )
85
- if validate_schema_file is not None and not args_dict.get("skip_output_validation", True):
78
+ skip_output_validation = settings.config.args.get("skip_output_validation", True)
79
+ if validate_schema_file is not None and not skip_output_validation:
86
80
  product_data = writer.validate_and_transform(
87
81
  product_data_table=product_data,
88
82
  validate_schema_file=validate_schema_file,
@@ -137,9 +131,8 @@ class ModelDataWriter:
137
131
  Validated parameter dictionary.
138
132
  """
139
133
  writer = ModelDataWriter(
140
- product_data_file=output_file,
141
- product_data_format="json",
142
- args_dict=None,
134
+ output_file=output_file,
135
+ output_file_format="json",
143
136
  output_path=output_path,
144
137
  )
145
138
  if check_db_for_existing_parameter:
@@ -434,19 +427,17 @@ class ModelDataWriter:
434
427
  gen.change_dict_keys_case(metadata.get_top_level_metadata(), True)
435
428
  )
436
429
 
437
- self._logger.info(f"Writing data to {self.product_data_file}")
438
- if isinstance(product_data, dict) and Path(self.product_data_file).suffix == ".json":
439
- self.write_dict_to_model_parameter_json(self.product_data_file, product_data)
430
+ self._logger.info(f"Writing data to {self.output_file}")
431
+ if isinstance(product_data, dict) and Path(self.output_file).suffix == ".json":
432
+ self.write_dict_to_model_parameter_json(self.output_file, product_data)
440
433
  return
441
434
  try:
442
- product_data.write(
443
- self.product_data_file, format=self.product_data_format, overwrite=True
444
- )
435
+ product_data.write(self.output_file, format=self.output_file_format, overwrite=True)
445
436
  except IORegistryError:
446
- self._logger.error(f"Error writing model data to {self.product_data_file}.")
437
+ self._logger.error(f"Error writing model data to {self.output_file}.")
447
438
  raise
448
439
  if metadata is not None:
449
- metadata.write(self.product_data_file, add_activity_name=True)
440
+ metadata.write(self.output_file, add_activity_name=True)
450
441
 
451
442
  def write_dict_to_model_parameter_json(self, file_name, data_dict):
452
443
  """
@@ -465,10 +456,13 @@ class ModelDataWriter:
465
456
  if data writing was not successful.
466
457
  """
467
458
  data_dict = ModelDataWriter.prepare_data_dict_for_writing(data_dict)
468
- self._logger.info(f"Writing data to {self.io_handler.get_output_file(file_name)}")
459
+ output_file = self.io_handler.get_output_file(
460
+ file_name, output_path_label=self.output_label
461
+ )
462
+ self._logger.info(f"Writing data to {output_file}")
469
463
  ascii_handler.write_data_to_file(
470
464
  data=data_dict,
471
- output_file=self.io_handler.get_output_file(file_name),
465
+ output_file=output_file,
472
466
  sort_keys=True,
473
467
  numpy_types=True,
474
468
  )
@@ -508,9 +502,12 @@ class ModelDataWriter:
508
502
  return data_dict
509
503
 
510
504
  @staticmethod
511
- def _astropy_data_format(product_data_format):
505
+ def _derive_data_format(product_data_format, output_file=None):
512
506
  """
513
- Ensure conformance with astropy data format naming.
507
+ Derive data format and ensure conformance with astropy data format naming.
508
+
509
+ If product_data_format is None and output_file is given, derive format
510
+ from output_file suffix.
514
511
 
515
512
  Parameters
516
513
  ----------
@@ -518,6 +515,6 @@ class ModelDataWriter:
518
515
  format identifier
519
516
 
520
517
  """
521
- if product_data_format == "ecsv":
522
- product_data_format = "ascii.ecsv"
523
- return product_data_format
518
+ if product_data_format is None and output_file is not None:
519
+ product_data_format = Path(output_file).suffix.lstrip(".")
520
+ return "ascii.ecsv" if product_data_format == "ecsv" else product_data_format
@@ -1,6 +1,7 @@
1
1
  """Module providing functionality to read and validate dictionaries using schema."""
2
2
 
3
3
  import logging
4
+ from functools import lru_cache
4
5
  from pathlib import Path
5
6
 
6
7
  import jsonschema
@@ -153,6 +154,7 @@ def _validate_meta_schema_url(data):
153
154
  raise FileNotFoundError(f"Meta schema URL does not exist: {data['meta_schema_url']}")
154
155
 
155
156
 
157
+ @lru_cache
156
158
  def _retrieve_yaml_schema_from_uri(uri):
157
159
  """Load schema from a file URI."""
158
160
  path = SCHEMA_PATH / Path(uri.removeprefix("file:/"))