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.
- dkist_processing_visp/__init__.py +1 -0
- dkist_processing_visp/config.py +1 -0
- dkist_processing_visp/models/constants.py +61 -20
- dkist_processing_visp/models/fits_access.py +20 -0
- dkist_processing_visp/models/metric_code.py +10 -0
- dkist_processing_visp/models/parameters.py +129 -24
- dkist_processing_visp/models/tags.py +22 -1
- dkist_processing_visp/models/task_name.py +1 -0
- dkist_processing_visp/parsers/map_repeats.py +1 -0
- dkist_processing_visp/parsers/modulator_states.py +1 -0
- dkist_processing_visp/parsers/polarimeter_mode.py +4 -2
- dkist_processing_visp/parsers/raster_step.py +4 -1
- dkist_processing_visp/parsers/spectrograph_configuration.py +75 -0
- dkist_processing_visp/parsers/time.py +24 -14
- dkist_processing_visp/parsers/visp_l0_fits_access.py +19 -8
- dkist_processing_visp/parsers/visp_l1_fits_access.py +1 -0
- dkist_processing_visp/tasks/__init__.py +1 -0
- dkist_processing_visp/tasks/assemble_movie.py +1 -0
- dkist_processing_visp/tasks/background_light.py +2 -1
- dkist_processing_visp/tasks/dark.py +5 -4
- dkist_processing_visp/tasks/geometric.py +132 -20
- dkist_processing_visp/tasks/instrument_polarization.py +128 -18
- dkist_processing_visp/tasks/l1_output_data.py +203 -0
- dkist_processing_visp/tasks/lamp.py +53 -93
- dkist_processing_visp/tasks/make_movie_frames.py +8 -6
- dkist_processing_visp/tasks/mixin/beam_access.py +1 -0
- dkist_processing_visp/tasks/mixin/corrections.py +54 -4
- dkist_processing_visp/tasks/mixin/downsample.py +1 -0
- dkist_processing_visp/tasks/parse.py +50 -17
- dkist_processing_visp/tasks/quality_metrics.py +5 -4
- dkist_processing_visp/tasks/science.py +126 -46
- dkist_processing_visp/tasks/solar.py +896 -456
- dkist_processing_visp/tasks/visp_base.py +4 -3
- dkist_processing_visp/tasks/write_l1.py +38 -10
- dkist_processing_visp/tests/conftest.py +145 -47
- dkist_processing_visp/tests/header_models.py +157 -20
- dkist_processing_visp/tests/local_trial_workflows/l0_cals_only.py +21 -78
- dkist_processing_visp/tests/local_trial_workflows/l0_polcals_as_science.py +421 -0
- dkist_processing_visp/tests/local_trial_workflows/l0_solar_gain_as_science.py +387 -0
- dkist_processing_visp/tests/local_trial_workflows/l0_to_l1.py +18 -75
- dkist_processing_visp/tests/local_trial_workflows/local_trial_helpers.py +346 -14
- dkist_processing_visp/tests/test_assemble_movie.py +2 -3
- dkist_processing_visp/tests/test_assemble_quality.py +89 -4
- dkist_processing_visp/tests/test_background_light.py +51 -44
- dkist_processing_visp/tests/test_dark.py +4 -3
- dkist_processing_visp/tests/test_downsample.py +1 -0
- dkist_processing_visp/tests/test_fits_access.py +43 -0
- dkist_processing_visp/tests/test_geometric.py +45 -4
- dkist_processing_visp/tests/test_instrument_polarization.py +72 -9
- dkist_processing_visp/tests/test_lamp.py +22 -26
- dkist_processing_visp/tests/test_make_movie_frames.py +4 -4
- dkist_processing_visp/tests/test_map_repeats.py +3 -1
- dkist_processing_visp/tests/test_parameters.py +122 -21
- dkist_processing_visp/tests/test_parse.py +164 -18
- dkist_processing_visp/tests/test_quality.py +3 -4
- dkist_processing_visp/tests/test_science.py +113 -15
- dkist_processing_visp/tests/test_solar.py +318 -99
- dkist_processing_visp/tests/test_visp_constants.py +38 -8
- dkist_processing_visp/tests/test_workflows.py +1 -0
- dkist_processing_visp/tests/test_write_l1.py +22 -3
- dkist_processing_visp/workflows/__init__.py +1 -0
- dkist_processing_visp/workflows/l0_processing.py +10 -3
- dkist_processing_visp/workflows/trial_workflows.py +8 -2
- dkist_processing_visp-5.1.1.dist-info/METADATA +552 -0
- dkist_processing_visp-5.1.1.dist-info/RECORD +94 -0
- {dkist_processing_visp-2.20.14.dist-info → dkist_processing_visp-5.1.1.dist-info}/WHEEL +1 -1
- docs/conf.py +5 -1
- docs/gain_correction.rst +52 -44
- docs/science_calibration.rst +7 -0
- dkist_processing_visp/tasks/mixin/line_zones.py +0 -115
- dkist_processing_visp-2.20.14.dist-info/METADATA +0 -196
- dkist_processing_visp-2.20.14.dist-info/RECORD +0 -89
- {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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
66
|
-
key_to_petal_dict: dict[str | Path,
|
|
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 =
|
|
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(
|
|
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.
|
|
33
|
-
self.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
38
|
-
self.
|
|
39
|
-
self.
|
|
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 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
93
|
-
with self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"""
|
|
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.
|
|
1079
|
-
"width": self.parameters.
|
|
1080
|
-
"bg_order": self.parameters.
|
|
1081
|
-
"normalization_percentile": self.parameters.
|
|
1082
|
-
"rel_height": self.parameters.
|
|
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)."""
|