dkist-processing-visp 2.20.14__py3-none-any.whl → 5.1.1__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 (73) hide show
  1. dkist_processing_visp/__init__.py +1 -0
  2. dkist_processing_visp/config.py +1 -0
  3. dkist_processing_visp/models/constants.py +61 -20
  4. dkist_processing_visp/models/fits_access.py +20 -0
  5. dkist_processing_visp/models/metric_code.py +10 -0
  6. dkist_processing_visp/models/parameters.py +129 -24
  7. dkist_processing_visp/models/tags.py +22 -1
  8. dkist_processing_visp/models/task_name.py +1 -0
  9. dkist_processing_visp/parsers/map_repeats.py +1 -0
  10. dkist_processing_visp/parsers/modulator_states.py +1 -0
  11. dkist_processing_visp/parsers/polarimeter_mode.py +4 -2
  12. dkist_processing_visp/parsers/raster_step.py +4 -1
  13. dkist_processing_visp/parsers/spectrograph_configuration.py +75 -0
  14. dkist_processing_visp/parsers/time.py +24 -14
  15. dkist_processing_visp/parsers/visp_l0_fits_access.py +19 -8
  16. dkist_processing_visp/parsers/visp_l1_fits_access.py +1 -0
  17. dkist_processing_visp/tasks/__init__.py +1 -0
  18. dkist_processing_visp/tasks/assemble_movie.py +1 -0
  19. dkist_processing_visp/tasks/background_light.py +2 -1
  20. dkist_processing_visp/tasks/dark.py +5 -4
  21. dkist_processing_visp/tasks/geometric.py +132 -20
  22. dkist_processing_visp/tasks/instrument_polarization.py +128 -18
  23. dkist_processing_visp/tasks/l1_output_data.py +203 -0
  24. dkist_processing_visp/tasks/lamp.py +53 -93
  25. dkist_processing_visp/tasks/make_movie_frames.py +8 -6
  26. dkist_processing_visp/tasks/mixin/beam_access.py +1 -0
  27. dkist_processing_visp/tasks/mixin/corrections.py +54 -4
  28. dkist_processing_visp/tasks/mixin/downsample.py +1 -0
  29. dkist_processing_visp/tasks/parse.py +50 -17
  30. dkist_processing_visp/tasks/quality_metrics.py +5 -4
  31. dkist_processing_visp/tasks/science.py +126 -46
  32. dkist_processing_visp/tasks/solar.py +896 -456
  33. dkist_processing_visp/tasks/visp_base.py +4 -3
  34. dkist_processing_visp/tasks/write_l1.py +38 -10
  35. dkist_processing_visp/tests/conftest.py +145 -47
  36. dkist_processing_visp/tests/header_models.py +157 -20
  37. dkist_processing_visp/tests/local_trial_workflows/l0_cals_only.py +21 -78
  38. dkist_processing_visp/tests/local_trial_workflows/l0_polcals_as_science.py +421 -0
  39. dkist_processing_visp/tests/local_trial_workflows/l0_solar_gain_as_science.py +387 -0
  40. dkist_processing_visp/tests/local_trial_workflows/l0_to_l1.py +18 -75
  41. dkist_processing_visp/tests/local_trial_workflows/local_trial_helpers.py +346 -14
  42. dkist_processing_visp/tests/test_assemble_movie.py +2 -3
  43. dkist_processing_visp/tests/test_assemble_quality.py +89 -4
  44. dkist_processing_visp/tests/test_background_light.py +51 -44
  45. dkist_processing_visp/tests/test_dark.py +4 -3
  46. dkist_processing_visp/tests/test_downsample.py +1 -0
  47. dkist_processing_visp/tests/test_fits_access.py +43 -0
  48. dkist_processing_visp/tests/test_geometric.py +45 -4
  49. dkist_processing_visp/tests/test_instrument_polarization.py +72 -9
  50. dkist_processing_visp/tests/test_lamp.py +22 -26
  51. dkist_processing_visp/tests/test_make_movie_frames.py +4 -4
  52. dkist_processing_visp/tests/test_map_repeats.py +3 -1
  53. dkist_processing_visp/tests/test_parameters.py +122 -21
  54. dkist_processing_visp/tests/test_parse.py +164 -18
  55. dkist_processing_visp/tests/test_quality.py +3 -4
  56. dkist_processing_visp/tests/test_science.py +113 -15
  57. dkist_processing_visp/tests/test_solar.py +318 -99
  58. dkist_processing_visp/tests/test_visp_constants.py +38 -8
  59. dkist_processing_visp/tests/test_workflows.py +1 -0
  60. dkist_processing_visp/tests/test_write_l1.py +22 -3
  61. dkist_processing_visp/workflows/__init__.py +1 -0
  62. dkist_processing_visp/workflows/l0_processing.py +10 -3
  63. dkist_processing_visp/workflows/trial_workflows.py +8 -2
  64. dkist_processing_visp-5.1.1.dist-info/METADATA +552 -0
  65. dkist_processing_visp-5.1.1.dist-info/RECORD +94 -0
  66. {dkist_processing_visp-2.20.14.dist-info → dkist_processing_visp-5.1.1.dist-info}/WHEEL +1 -1
  67. docs/conf.py +5 -1
  68. docs/gain_correction.rst +52 -44
  69. docs/science_calibration.rst +7 -0
  70. dkist_processing_visp/tasks/mixin/line_zones.py +0 -115
  71. dkist_processing_visp-2.20.14.dist-info/METADATA +0 -196
  72. dkist_processing_visp-2.20.14.dist-info/RECORD +0 -89
  73. {dkist_processing_visp-2.20.14.dist-info → dkist_processing_visp-5.1.1.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,9 @@
1
1
  """Stems for parsing constants and tags related to time header keys."""
2
- from collections import namedtuple
2
+
3
3
  from pathlib import Path
4
+ from typing import NamedTuple
4
5
 
6
+ from dkist_processing_common.models.fits_access import MetadataKey
5
7
  from dkist_processing_common.models.flower_pot import SpilledDirt
6
8
  from dkist_processing_common.models.flower_pot import Stem
7
9
  from dkist_processing_common.models.flower_pot import Thorn
@@ -12,16 +14,12 @@ from dkist_processing_visp.models.constants import VispBudName
12
14
  from dkist_processing_visp.parsers.visp_l0_fits_access import VispL0FitsAccess
13
15
 
14
16
 
15
- class NonDarkTaskReadoutExpTimesBud(Stem):
16
- """
17
- Produce a tuple of all exposure times present in the dataset for ip task types that are not DARK.
18
-
19
- POLCAL is included here because ViSP, unlike most other instruments, uses the IP task DARK frames for correction.
20
- """
17
+ class NonDarkNonPolcalTaskReadoutExpTimesBud(Stem):
18
+ """Produce a tuple of all exposure times present in the dataset for ip task types that are not DARK or POLCAL."""
21
19
 
22
20
  def __init__(self):
23
21
  super().__init__(stem_name=VispBudName.non_dark_task_readout_exp_times.value)
24
- self.metadata_key = "sensor_readout_exposure_time_ms"
22
+ self.metadata_key = MetadataKey.sensor_readout_exposure_time_ms.name
25
23
 
26
24
  def setter(self, fits_obj: VispL0FitsAccess) -> float | SpilledDirt:
27
25
  """
@@ -36,7 +34,8 @@ class NonDarkTaskReadoutExpTimesBud(Stem):
36
34
  -------
37
35
  The exposure time associated with this fits object
38
36
  """
39
- if fits_obj.ip_task_type.casefold() != TaskName.dark.value.casefold():
37
+ excluded_task_types = [TaskName.dark.value.casefold(), TaskName.polcal.value.casefold()]
38
+ if fits_obj.ip_task_type.casefold() not in excluded_task_types:
40
39
  raw_exposure_time = getattr(fits_obj, self.metadata_key)
41
40
  return round(raw_exposure_time, EXP_TIME_ROUND_DIGITS)
42
41
 
@@ -59,15 +58,23 @@ class NonDarkTaskReadoutExpTimesBud(Stem):
59
58
  return exposure_times
60
59
 
61
60
 
61
+ class ReadoutExposureTimeContainer(NamedTuple):
62
+ """Named tuple to hold whether the task is dark and/or polcal along with the associated exposure time."""
63
+
64
+ is_dark: bool
65
+ is_polcal: bool
66
+ readout_exposure_time: float
67
+
68
+
62
69
  class DarkReadoutExpTimePickyBud(Stem):
63
70
  """Parse exposure times to ensure existence of the necessary DARK exposure times."""
64
71
 
65
- ReadoutExposureTime = namedtuple("ReadoutExposureTime", ["is_dark", "readout_exposure_time"])
66
- key_to_petal_dict: dict[str | Path, ReadoutExposureTime] # For type hinting
72
+ ReadoutExposureTime: ReadoutExposureTimeContainer = ReadoutExposureTimeContainer
73
+ key_to_petal_dict: dict[str | Path, ReadoutExposureTimeContainer] # For type hinting
67
74
 
68
75
  def __init__(self):
69
76
  super().__init__(stem_name=VispBudName.dark_readout_exp_time_picky_bud.value)
70
- self.metadata_key = "sensor_readout_exposure_time_ms"
77
+ self.metadata_key = MetadataKey.sensor_readout_exposure_time_ms.name
71
78
 
72
79
  def setter(self, fits_obj: VispL0FitsAccess) -> tuple:
73
80
  """
@@ -84,8 +91,11 @@ class DarkReadoutExpTimePickyBud(Stem):
84
91
  raw_exposure_time = getattr(fits_obj, self.metadata_key)
85
92
  exposure_time = round(raw_exposure_time, EXP_TIME_ROUND_DIGITS)
86
93
  is_dark = fits_obj.ip_task_type.casefold() == TaskName.dark.value.casefold()
94
+ is_polcal = fits_obj.ip_task_type.casefold() == TaskName.polcal.value.casefold()
87
95
 
88
- return self.ReadoutExposureTime(is_dark=is_dark, readout_exposure_time=exposure_time)
96
+ return self.ReadoutExposureTime(
97
+ is_dark=is_dark, is_polcal=is_polcal, readout_exposure_time=exposure_time
98
+ )
89
99
 
90
100
  def getter(self, key: str | Path) -> Thorn:
91
101
  """
@@ -109,7 +119,7 @@ class DarkReadoutExpTimePickyBud(Stem):
109
119
  required_readout_exp_times = {
110
120
  exp_time.readout_exposure_time
111
121
  for exp_time in readout_exp_tuples
112
- if not exp_time.is_dark
122
+ if (not exp_time.is_dark and not exp_time.is_polcal)
113
123
  }
114
124
 
115
125
  required_exp_times_missing_from_dark_exposure_times = (
@@ -1,7 +1,10 @@
1
1
  """ViSP FITS access for L0 data."""
2
+
2
3
  from astropy.io import fits
3
4
  from dkist_processing_common.parsers.l0_fits_access import L0FitsAccess
4
5
 
6
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
7
+
5
8
 
6
9
  class VispL0FitsAccess(L0FitsAccess):
7
10
  """
@@ -29,11 +32,19 @@ class VispL0FitsAccess(L0FitsAccess):
29
32
  ):
30
33
  super().__init__(hdu=hdu, name=name, auto_squeeze=auto_squeeze)
31
34
 
32
- self.number_of_modulator_states: int = self.header["VSPNUMST"]
33
- self.raster_scan_step: int = self.header["VSPSTP"]
34
- self.total_raster_steps: int = self.header["VSPNSTP"]
35
- self.modulator_state: int = self.header["VSPSTNUM"]
36
- self.polarimeter_mode: str = self.header["VISP_006"]
37
- self.axis_1_type: str = self.header["CTYPE1"]
38
- self.axis_2_type: str = self.header["CTYPE2"]
39
- self.axis_3_type: str = self.header["CTYPE3"]
35
+ self.arm_id: int = self.header[VispMetadataKey.arm_id]
36
+ self.number_of_modulator_states: int = self.header[
37
+ VispMetadataKey.number_of_modulator_states
38
+ ]
39
+ self.raster_scan_step: int = self.header[VispMetadataKey.raster_scan_step]
40
+ self.total_raster_steps: int = self.header[VispMetadataKey.total_raster_steps]
41
+ self.modulator_state: int = self.header[VispMetadataKey.modulator_state]
42
+ self.polarimeter_mode: str = self.header[VispMetadataKey.polarimeter_mode]
43
+ self.grating_angle_deg: float = self.header[VispMetadataKey.grating_angle_deg]
44
+ self.arm_position_deg: float = self.header[VispMetadataKey.arm_position_deg]
45
+ self.grating_constant_inverse_mm: float = self.header[
46
+ VispMetadataKey.grating_constant_inverse_mm
47
+ ]
48
+ self.axis_1_type: str = self.header[VispMetadataKey.axis_1_type]
49
+ self.axis_2_type: str = self.header[VispMetadataKey.axis_2_type]
50
+ self.axis_3_type: str = self.header[VispMetadataKey.axis_3_type]
@@ -1,4 +1,5 @@
1
1
  """ViSP FITS access for L1 data."""
2
+
2
3
  from astropy.io import fits
3
4
  from dkist_processing_common.parsers.l1_fits_access import L1FitsAccess
4
5
 
@@ -1,4 +1,5 @@
1
1
  """init."""
2
+
2
3
  from dkist_processing_visp.tasks.assemble_movie import *
3
4
  from dkist_processing_visp.tasks.background_light import *
4
5
  from dkist_processing_visp.tasks.dark import *
@@ -1,4 +1,5 @@
1
1
  """ViSP-specific assemble movie task subclass."""
2
+
2
3
  from dkist_processing_common.tasks import AssembleMovie
3
4
  from PIL import ImageDraw
4
5
 
@@ -1,4 +1,5 @@
1
1
  """ViSP background light calibration task."""
2
+
2
3
  import gc
3
4
  import time
4
5
 
@@ -114,7 +115,7 @@ class BackgroundLightCalibration(
114
115
  del full_background_light
115
116
  gc.collect()
116
117
 
117
- with self.apm_processing_step("Computing and logging quality metrics"):
118
+ with self.telemetry_span("Computing and logging quality metrics"):
118
119
  self.quality_store_task_type_counts(
119
120
  task_type=VispTaskName.background.value,
120
121
  total_frames=num_used_polcal_files,
@@ -1,4 +1,5 @@
1
1
  """Visp dark task."""
2
+
2
3
  from dkist_processing_common.codecs.fits import fits_access_decoder
3
4
  from dkist_processing_common.codecs.fits import fits_array_encoder
4
5
  from dkist_processing_common.models.task_name import TaskName
@@ -49,7 +50,7 @@ class DarkCalibration(VispTaskBase, BeamAccessMixin, QualityMixin):
49
50
  required_readout_exp_times = list(self.constants.non_dark_task_readout_exp_times)
50
51
  logger.info(f"{required_readout_exp_times = }")
51
52
 
52
- with self.apm_task_step(
53
+ with self.telemetry_span(
53
54
  f"Calculating dark frames for {self.constants.num_beams} beams and "
54
55
  f"{len(required_readout_exp_times)} readout exp times"
55
56
  ):
@@ -74,7 +75,7 @@ class DarkCalibration(VispTaskBase, BeamAccessMixin, QualityMixin):
74
75
  fits_access_class=VispL0FitsAccess,
75
76
  )
76
77
 
77
- with self.apm_processing_step(
78
+ with self.telemetry_span(
78
79
  f"Calculating dark for {readout_exp_time = } and {beam = }"
79
80
  ):
80
81
  readout_normalized_arrays = (
@@ -83,7 +84,7 @@ class DarkCalibration(VispTaskBase, BeamAccessMixin, QualityMixin):
83
84
  )
84
85
  averaged_dark_array = average_numpy_arrays(readout_normalized_arrays)
85
86
 
86
- with self.apm_writing_step(f"Writing dark for {readout_exp_time = } {beam = }"):
87
+ with self.telemetry_span(f"Writing dark for {readout_exp_time = } {beam = }"):
87
88
  self.write(
88
89
  data=averaged_dark_array,
89
90
  tags=VispTag.intermediate_frame_dark(
@@ -92,7 +93,7 @@ class DarkCalibration(VispTaskBase, BeamAccessMixin, QualityMixin):
92
93
  encoder=fits_array_encoder,
93
94
  )
94
95
 
95
- with self.apm_processing_step("Computing and logging quality metrics"):
96
+ with self.telemetry_span("Computing and logging quality metrics"):
96
97
  no_of_raw_dark_frames: int = self.scratch.count_all(
97
98
  tags=[
98
99
  VispTag.input(),
@@ -3,12 +3,15 @@ Visp geometric calibration task.
3
3
 
4
4
  See :doc:`this page </geometric>` for more information.
5
5
  """
6
+
6
7
  from typing import Generator
7
8
 
8
9
  import numpy as np
10
+ import peakutils
9
11
  import peakutils as pku
10
12
  import scipy.ndimage as spnd
11
13
  import scipy.optimize as spo
14
+ import scipy.signal as sps
12
15
  import skimage.exposure as skie
13
16
  import skimage.metrics as skim
14
17
  import skimage.morphology as skimo
@@ -32,7 +35,6 @@ from dkist_processing_visp.models.tags import VispTag
32
35
  from dkist_processing_visp.parsers.visp_l0_fits_access import VispL0FitsAccess
33
36
  from dkist_processing_visp.tasks.mixin.beam_access import BeamAccessMixin
34
37
  from dkist_processing_visp.tasks.mixin.corrections import CorrectionsMixin
35
- from dkist_processing_visp.tasks.mixin.line_zones import LineZonesMixin
36
38
  from dkist_processing_visp.tasks.visp_base import VispTaskBase
37
39
 
38
40
  __all__ = ["GeometricCalibration"]
@@ -43,7 +45,6 @@ class GeometricCalibration(
43
45
  BeamAccessMixin,
44
46
  CorrectionsMixin,
45
47
  QualityMixin,
46
- LineZonesMixin,
47
48
  ):
48
49
  """
49
50
  Task class for computing the spectral geometry. Geometry is represented by three quantities.
@@ -85,15 +86,15 @@ class GeometricCalibration(
85
86
  """
86
87
  # This lives outside the run() loops and has its own internal loops because the angle calculation
87
88
  # only happens for a single modstate
88
- with self.apm_processing_step("Basic corrections"):
89
+ with self.telemetry_span("Basic corrections"):
89
90
  self.do_basic_corrections()
90
91
 
91
92
  for beam in range(1, self.constants.num_beams + 1):
92
- with self.apm_task_step(f"Generating geometric calibrations for {beam = }"):
93
- with self.apm_processing_step(f"Computing and writing angle for {beam = }"):
93
+ with self.telemetry_span(f"Generating geometric calibrations for {beam = }"):
94
+ with self.telemetry_span(f"Computing and writing angle for {beam = }"):
94
95
  angle = self.compute_beam_angle(beam=beam)
95
96
  if beam == 2:
96
- with self.apm_processing_step("Refining beam 2 angle"):
97
+ with self.telemetry_span("Refining beam 2 angle"):
97
98
  ang_corr = self.refine_beam2_angle(angle)
98
99
  logger.info(f"Beam 2 angle refinement = {np.rad2deg(ang_corr)} deg")
99
100
  angle += ang_corr
@@ -101,14 +102,12 @@ class GeometricCalibration(
101
102
  self.write_angle(angle=angle, beam=beam)
102
103
 
103
104
  for modstate in range(1, self.constants.num_modstates + 1):
104
- with self.apm_processing_step(
105
- f"Removing angle from {beam = } and {modstate = }"
106
- ):
105
+ with self.telemetry_span(f"Removing angle from {beam = } and {modstate = }"):
107
106
  angle_corr_array = self.remove_beam_angle(
108
107
  angle=angle, beam=beam, modstate=modstate
109
108
  )
110
109
 
111
- with self.apm_processing_step(
110
+ with self.telemetry_span(
112
111
  f"Computing state offset for {beam = } and {modstate = }"
113
112
  ):
114
113
  state_offset = self.compute_modstate_offset(
@@ -116,7 +115,7 @@ class GeometricCalibration(
116
115
  )
117
116
  self.write_state_offset(offset=state_offset, beam=beam, modstate=modstate)
118
117
 
119
- with self.apm_processing_step(
118
+ with self.telemetry_span(
120
119
  f"Removing state offset for {beam = } and {modstate = }"
121
120
  ):
122
121
  self.remove_state_offset(
@@ -126,13 +125,13 @@ class GeometricCalibration(
126
125
  modstate=modstate,
127
126
  )
128
127
 
129
- with self.apm_processing_step(f"Computing spectral shifts for {beam = }"):
128
+ with self.telemetry_span(f"Computing spectral shifts for {beam = }"):
130
129
  spec_shifts = self.compute_spectral_shifts(beam=beam)
131
130
 
132
- with self.apm_writing_step(f"Writing spectral shifts for {beam = }"):
131
+ with self.telemetry_span(f"Writing spectral shifts for {beam = }"):
133
132
  self.write_spectral_shifts(shifts=spec_shifts, beam=beam)
134
133
 
135
- with self.apm_processing_step("Computing and logging quality metrics"):
134
+ with self.telemetry_span("Computing and logging quality metrics"):
136
135
  no_of_raw_geo_frames: int = self.scratch.count_all(
137
136
  tags=[
138
137
  VispTag.input(),
@@ -275,7 +274,12 @@ class GeometricCalibration(
275
274
  self.prep_input_solar_gain()
276
275
 
277
276
  def prep_lamp_gain(self):
278
- """Apply dark corrections fo INPUT lamp frames."""
277
+ """
278
+ Create average, dark-corrected lamp gain images for each modstate from INPUT lamp gains.
279
+
280
+ This is different from the results of the `~dkist_processing_visp.tasks.lamp.LampCalibration` task because in
281
+ that task the hairlines are masked out, but here we *need* the hairlines to compute the rotation angle.
282
+ """
279
283
  for readout_exp_time in self.constants.lamp_readout_exp_times:
280
284
  for beam in range(1, self.constants.num_beams + 1):
281
285
  logger.info(
@@ -1075,11 +1079,11 @@ class GeometricCalibration(
1075
1079
  `compute_single_state_offset`.
1076
1080
  """
1077
1081
  zone_kwargs = {
1078
- "prominence": self.parameters.solar_zone_prominence,
1079
- "width": self.parameters.solar_zone_width,
1080
- "bg_order": self.parameters.solar_zone_bg_order,
1081
- "normalization_percentile": self.parameters.solar_zone_normalization_percentile,
1082
- "rel_height": self.parameters.solar_zone_rel_height,
1082
+ "prominence": self.parameters.geo_zone_prominence,
1083
+ "width": self.parameters.geo_zone_width,
1084
+ "bg_order": self.parameters.geo_zone_bg_order,
1085
+ "normalization_percentile": self.parameters.geo_zone_normalization_percentile,
1086
+ "rel_height": self.parameters.geo_zone_rel_height,
1083
1087
  }
1084
1088
  zones = self.compute_line_zones(array, **zone_kwargs)
1085
1089
  logger.info(f"Found {zones = }")
@@ -1089,6 +1093,114 @@ class GeometricCalibration(
1089
1093
 
1090
1094
  return mask
1091
1095
 
1096
+ def compute_line_zones(
1097
+ self,
1098
+ spec_2d: np.ndarray,
1099
+ prominence: float = 0.2,
1100
+ width: float = 2,
1101
+ bg_order: int = 22,
1102
+ normalization_percentile: int = 99,
1103
+ rel_height: float = 0.97,
1104
+ ) -> list[tuple[int, int]]:
1105
+ """
1106
+ Identify spectral regions around strong spectra features.
1107
+
1108
+ Parameters
1109
+ ----------
1110
+ spec_2d
1111
+ Data
1112
+
1113
+ prominence
1114
+ Zone prominence threshold used to identify strong spectral features
1115
+
1116
+ width
1117
+ Zone width
1118
+
1119
+ bg_order
1120
+ Order of polynomial fit used to remove continuum when identifying strong spectral features
1121
+
1122
+ normalization_percentile
1123
+ Compute this percentile of the data along a specified axis
1124
+
1125
+ rel_height
1126
+ The relative height at which the peak width is measured as a percentage of its prominence. E.g., 1.0 measures
1127
+ the peak width at the lowest contour line.
1128
+
1129
+ Returns
1130
+ -------
1131
+ regions
1132
+ List of indices defining the found spectral lines
1133
+
1134
+ """
1135
+ logger.info(
1136
+ f"Finding zones using {prominence=}, {width=}, {bg_order=}, {normalization_percentile=}, and {rel_height=}"
1137
+ )
1138
+ # Compute average along slit to improve signal. Line smearing isn't important here
1139
+ avg_1d = np.mean(spec_2d, axis=1)
1140
+
1141
+ # Convert to an emission spectrum and remove baseline continuum so peakutils has an easier time
1142
+ em_spec = -1 * avg_1d + avg_1d.max()
1143
+ em_spec /= np.nanpercentile(em_spec, normalization_percentile)
1144
+ baseline = peakutils.baseline(em_spec, bg_order)
1145
+ em_spec -= baseline
1146
+
1147
+ # Find indices of peaks
1148
+ peak_idxs = sps.find_peaks(em_spec, prominence=prominence, width=width)[0]
1149
+
1150
+ # Find the rough width based only on the height of the peak
1151
+ # rips and lips are the right and left borders of the region around the peak
1152
+ _, _, rips, lips = sps.peak_widths(em_spec, peak_idxs, rel_height=rel_height)
1153
+
1154
+ # Convert to ints so they can be used as indices
1155
+ rips = np.floor(rips).astype(int)
1156
+ lips = np.ceil(lips).astype(int)
1157
+
1158
+ # Remove any regions that are contained within another region
1159
+ ranges_to_remove = self.identify_overlapping_zones(rips, lips)
1160
+ rips = np.delete(rips, ranges_to_remove)
1161
+ lips = np.delete(lips, ranges_to_remove)
1162
+
1163
+ return list(zip(rips, lips))
1164
+
1165
+ @staticmethod
1166
+ def identify_overlapping_zones(rips: np.ndarray, lips: np.ndarray) -> list[int]:
1167
+ """
1168
+ Identify line zones that overlap with other zones. Any overlap greater than 1 pixel is flagged.
1169
+
1170
+ Parameters
1171
+ ----------
1172
+ rips
1173
+ Right borders of the region around the peak
1174
+
1175
+ lips
1176
+ Left borders of the region around the peak
1177
+
1178
+ Returns
1179
+ -------
1180
+ overlapping regions
1181
+ List indices into the input arrays that represent an overlapped region that can be removed
1182
+ """
1183
+ all_ranges = [np.arange(zmin, zmax) for zmin, zmax in zip(rips, lips)]
1184
+ ranges_to_remove = []
1185
+ for i in range(len(all_ranges)):
1186
+ target_range = all_ranges[i]
1187
+ for j in range(i + 1, len(all_ranges)):
1188
+ if (
1189
+ np.intersect1d(target_range, all_ranges[j]).size > 1
1190
+ ): # Allow for a single overlap just to be nice
1191
+ if target_range.size > all_ranges[j].size:
1192
+ ranges_to_remove.append(j)
1193
+ logger.info(
1194
+ f"Zone ({all_ranges[j][0]}, {all_ranges[j][-1]}) inside zone ({target_range[0]}, {target_range[-1]})"
1195
+ )
1196
+ else:
1197
+ ranges_to_remove.append(i)
1198
+ logger.info(
1199
+ f"Zone ({target_range[0]}, {target_range[-1]}) inside zone ({all_ranges[j][0]}, {all_ranges[j][-1]})"
1200
+ )
1201
+
1202
+ return ranges_to_remove
1203
+
1092
1204
  @staticmethod
1093
1205
  def high_pass_filter_array(array: np.ndarray) -> np.ndarray:
1094
1206
  """Perform a simple high-pass filter to accentuate narrow features (hairlines and spectra)."""