dkist-processing-common 10.8.2__py3-none-any.whl → 10.8.4rc1__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 (30) hide show
  1. changelog/235.feature.rst +3 -0
  2. changelog/235.misc.1.rst +2 -0
  3. changelog/235.misc.rst +1 -0
  4. dkist_processing_common/codecs/array.py +19 -0
  5. dkist_processing_common/codecs/basemodel.py +21 -0
  6. dkist_processing_common/codecs/fits.py +12 -6
  7. dkist_processing_common/manual.py +3 -5
  8. dkist_processing_common/models/fried_parameter.py +41 -0
  9. dkist_processing_common/models/graphql.py +13 -3
  10. dkist_processing_common/models/input_dataset.py +113 -0
  11. dkist_processing_common/models/parameters.py +65 -28
  12. dkist_processing_common/parsers/quality.py +1 -0
  13. dkist_processing_common/tasks/mixin/metadata_store.py +7 -4
  14. dkist_processing_common/tasks/mixin/quality/_metrics.py +19 -14
  15. dkist_processing_common/tasks/quality_metrics.py +1 -1
  16. dkist_processing_common/tasks/transfer_input_data.py +61 -70
  17. dkist_processing_common/tasks/write_l1.py +9 -2
  18. dkist_processing_common/tests/conftest.py +24 -7
  19. dkist_processing_common/tests/test_codecs.py +38 -0
  20. dkist_processing_common/tests/test_fried_parameter.py +27 -0
  21. dkist_processing_common/tests/test_input_dataset.py +79 -308
  22. dkist_processing_common/tests/test_parameters.py +71 -22
  23. dkist_processing_common/tests/test_quality_mixin.py +32 -22
  24. dkist_processing_common/tests/test_transfer_input_data.py +131 -45
  25. dkist_processing_common/tests/test_write_l1.py +35 -10
  26. {dkist_processing_common-10.8.2.dist-info → dkist_processing_common-10.8.4rc1.dist-info}/METADATA +1 -1
  27. {dkist_processing_common-10.8.2.dist-info → dkist_processing_common-10.8.4rc1.dist-info}/RECORD +29 -22
  28. dkist_processing_common/tasks/mixin/input_dataset.py +0 -166
  29. {dkist_processing_common-10.8.2.dist-info → dkist_processing_common-10.8.4rc1.dist-info}/WHEEL +0 -0
  30. {dkist_processing_common-10.8.2.dist-info → dkist_processing_common-10.8.4rc1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,3 @@
1
+ Add two new codecs: Basemodel codecs are used for encoding and decoding Pydantic BaseModel objects. For decoding, the intended model
2
+ is passed to the decoder through a keyword argument in the task read method. Array codecs are used for encoding and decoding numpy
3
+ arrays similar to the standard np.load() and np.save(), but with the task tag-based write method.
@@ -0,0 +1,2 @@
1
+ Change the behavior of ParameterBase such that it takes the task scratch as an argument to provide access to the
2
+ parameter document and parameters that are files. This behavior replaces the input dataset mixin parameter read method.
changelog/235.misc.rst ADDED
@@ -0,0 +1 @@
1
+ Remove the input_dataset mixin and replace it with input_dataset Pydantic BaseModel models.
@@ -0,0 +1,19 @@
1
+ """Encoder/decoder for writing/reading numpy arrays."""
2
+ import io
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+
7
+ from dkist_processing_common.codecs.iobase import iobase_encoder
8
+
9
+
10
+ def array_encoder(data: np.ndarray, **np_kwargs) -> bytes:
11
+ """Convert a numpy array to bytes compatible with np.load()."""
12
+ buffer = io.BytesIO()
13
+ np.save(buffer, data, **np_kwargs)
14
+ return iobase_encoder(buffer)
15
+
16
+
17
+ def array_decoder(path: Path, **np_kwargs) -> np.ndarray:
18
+ """Return the data in the file as a numpy array using np.load()."""
19
+ return np.load(path, **np_kwargs)
@@ -0,0 +1,21 @@
1
+ """Encoder/decoder for writing and reading Pydantic BaseModel objects."""
2
+ from pathlib import Path
3
+ from typing import Type
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from dkist_processing_common.codecs.bytes import bytes_decoder
8
+ from dkist_processing_common.codecs.str import str_encoder
9
+
10
+
11
+ def basemodel_encoder(data: BaseModel, **basemodel_kwargs) -> bytes:
12
+ """Convert a Pydantic BaseModel object into bytes for writing to file."""
13
+ data_dump = data.model_dump_json(**basemodel_kwargs)
14
+ return str_encoder(data_dump)
15
+
16
+
17
+ def basemodel_decoder(path: Path, model: Type[BaseModel], **basemodel_kwargs) -> BaseModel:
18
+ """Return the data in the file as a Pydantic BaseModel object."""
19
+ data = bytes_decoder(path)
20
+ model_validated = model.model_validate_json(data, **basemodel_kwargs)
21
+ return model_validated
@@ -30,15 +30,15 @@ def fits_hdulist_encoder(hdu_list: fits.HDUList) -> bytes:
30
30
  return iobase_encoder(file_obj)
31
31
 
32
32
 
33
- def fits_hdu_decoder(path: Path) -> fits.PrimaryHDU | fits.CompImageHDU:
33
+ def fits_hdu_decoder(path: Path, hdu: int | None = None) -> fits.PrimaryHDU | fits.CompImageHDU:
34
34
  """Read a Path with `fits` to produce an `HDUList`."""
35
35
  hdu_list = fits.open(path, checksum=True)
36
- return _extract_hdu(hdu_list)
36
+ return _extract_hdu(hdu_list, hdu)
37
37
 
38
38
 
39
- def fits_array_decoder(path: Path, auto_squeeze: bool = True) -> np.ndarray:
39
+ def fits_array_decoder(path: Path, hdu: int | None = None, auto_squeeze: bool = True) -> np.ndarray:
40
40
  """Read a Path with `fits` and return the `.data` property."""
41
- hdu = fits_hdu_decoder(path)
41
+ hdu = fits_hdu_decoder(path, hdu=hdu)
42
42
  data = hdu.data
43
43
 
44
44
  # This conditional is explicitly to catch summit data with a dummy first axis for WCS
@@ -56,8 +56,14 @@ def fits_access_decoder(
56
56
  return fits_access_class(hdu=hdu, name=str(path), **fits_access_kwargs)
57
57
 
58
58
 
59
- def _extract_hdu(hdul: fits.HDUList) -> fits.PrimaryHDU | fits.CompImageHDU:
60
- """Return the fits hdu associated with the data in the hdu list."""
59
+ def _extract_hdu(hdul: fits.HDUList, hdu: int | None = None) -> fits.PrimaryHDU | fits.CompImageHDU:
60
+ """
61
+ Return the fits hdu associated with the data in the hdu list.
62
+
63
+ Only search down the hdu index for the data if the hdu index is not explicitly provided.
64
+ """
65
+ if hdu is not None:
66
+ return hdul[hdu]
61
67
  if hdul[0].data is not None:
62
68
  return hdul[0]
63
69
  return hdul[1]
@@ -2,15 +2,13 @@
2
2
  import json
3
3
  import logging
4
4
  import shutil
5
- from dataclasses import asdict
6
- from io import BytesIO
7
5
  from pathlib import Path
8
6
  from typing import Callable
9
7
  from unittest.mock import patch
10
8
 
11
9
  from dkist_processing_core.task import TaskBase
12
10
 
13
- from dkist_processing_common.codecs.json import json_encoder
11
+ from dkist_processing_common.codecs.basemodel import basemodel_encoder
14
12
  from dkist_processing_common.models.graphql import RecipeRunProvenanceMutation
15
13
  from dkist_processing_common.models.tags import Tag
16
14
  from dkist_processing_common.tasks.base import WorkflowTaskBase
@@ -182,8 +180,8 @@ def writing_metadata_store_record_provenance(self, is_task_manual: bool, library
182
180
  workflowVersion=self.workflow_version,
183
181
  )
184
182
  self.write(
185
- data=params.model_dump(),
186
- encoder=json_encoder,
183
+ data=params,
184
+ encoder=basemodel_encoder,
187
185
  tags=["PROVENANCE_RECORD"],
188
186
  relative_path=f"{self.task_name}_provenance.json",
189
187
  overwrite=True,
@@ -0,0 +1,41 @@
1
+ """Helper methods to handle fried parameter / r0 validity."""
2
+
3
+
4
+ def r0_valid(
5
+ r0: float | None = None,
6
+ ao_lock: bool | None = None,
7
+ num_out_of_bounds_ao_values: int | None = None,
8
+ ) -> bool:
9
+ """
10
+ Determine if the r0 value should be considered valid based on the following conditions.
11
+
12
+ * ATMOS_R0 does not exist in the header.
13
+ * the value of ATMOS_R0 is greater than 0.3m
14
+ * the AO is not locked
15
+ * the value of OOBSHIFT is greater than 100
16
+
17
+ When the adaptive optics system is not locked, the ATMOS_R0 keyword is still filled with the output of the
18
+ Fried parameter calculation. The inputs are not valid in this instance and the value should be removed.
19
+
20
+ Sometimes, due to timing differences between the calculation of the Fried parameter and the AO lock status being
21
+ updated, non-physical values can be recorded for ATMOS_R0 right on the edge of an AO_LOCK state change. To
22
+ combat this, any remaining R0 values greater than 30cm (which is beyond the realm of physical possibility for
23
+ solar observations) are also removed.
24
+
25
+ In addition, the number of AO out-of-bound values is given in the keyword OOBSHIFT and the AO team advises
26
+ that values under 100 are when the r0 value is considered reliable. If the OOBSHIFT key doesn't exist, this check
27
+ should be ignored.
28
+ """
29
+ if r0 is None:
30
+ return False
31
+
32
+ if r0 > 0.3:
33
+ return False
34
+
35
+ if ao_lock is not True:
36
+ return False
37
+
38
+ if num_out_of_bounds_ao_values is not None and num_out_of_bounds_ao_values > 100:
39
+ return False
40
+
41
+ return True
@@ -3,6 +3,9 @@ from pydantic import BaseModel
3
3
  from pydantic import field_validator
4
4
  from pydantic import Json
5
5
 
6
+ from dkist_processing_common.models.input_dataset import InputDatasetBaseModel
7
+ from dkist_processing_common.models.input_dataset import InputDatasetPartDocumentList
8
+
6
9
 
7
10
  class RecipeRunMutation(BaseModel):
8
11
  """Recipe run mutation record."""
@@ -37,13 +40,19 @@ class InputDatasetPartTypeResponse(BaseModel):
37
40
  inputDatasetPartTypeName: str
38
41
 
39
42
 
40
- class InputDatasetPartResponse(BaseModel):
43
+ class InputDatasetPartResponse(InputDatasetBaseModel):
41
44
  """Response class for the input dataset part entity."""
42
45
 
43
46
  inputDatasetPartId: int
44
- inputDatasetPartDocument: Json[list[dict]]
47
+ # inputDatasetPartDocument : Json[InputDatasetPartDocumentList] # will work in gqlclient v2
48
+ inputDatasetPartDocument: Json[list]
45
49
  inputDatasetPartType: InputDatasetPartTypeResponse
46
50
 
51
+ @field_validator("inputDatasetPartDocument", mode="after")
52
+ @classmethod
53
+ def _use_frame_or_parameter_model(cls, value_list): # not needed for gqlclient v2
54
+ return InputDatasetPartDocumentList(doc_list=value_list)
55
+
47
56
 
48
57
  class InputDatasetInputDatasetPartResponse(BaseModel):
49
58
  """Response class for the join entity between input datasets and input dataset parts."""
@@ -103,11 +112,12 @@ class RecipeRunResponse(BaseModel):
103
112
  recipeInstance: RecipeInstanceResponse
104
113
  recipeInstanceId: int
105
114
  recipeRunProvenances: list[RecipeRunProvenanceResponse]
115
+ # configuration: Json[RecipeRunConfiguration] | None # will work in gqlclient v2
106
116
  configuration: Json[dict] | None
107
117
 
108
118
  @field_validator("configuration", mode="after")
109
119
  @classmethod
110
- def _use_recipe_run_configuration_model(cls, value):
120
+ def _use_recipe_run_configuration_model(cls, value): # not needed for gqlclient v2
111
121
  if value is None:
112
122
  return RecipeRunConfiguration()
113
123
  return RecipeRunConfiguration.model_validate(value)
@@ -0,0 +1,113 @@
1
+ """Input dataset models for the inputDatasetPartDocument from the metadata store api."""
2
+ import json
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+ from pydantic import ConfigDict
8
+ from pydantic import Field
9
+ from pydantic import field_serializer
10
+ from pydantic import field_validator
11
+ from pydantic import Json
12
+ from pydantic import PlainSerializer
13
+ from pydantic.alias_generators import to_camel
14
+ from typing_extensions import Annotated
15
+
16
+
17
+ class InputDatasetBaseModel(BaseModel):
18
+ """Custom BaseModel for input datasets."""
19
+
20
+ model_config = ConfigDict(
21
+ alias_generator=to_camel, validate_by_name=True, validate_by_alias=True
22
+ )
23
+
24
+ def model_dump(self, **kwargs) -> dict:
25
+ """Dump models as they were in the metadata store."""
26
+ kwargs.setdefault("exclude_defaults", True)
27
+ kwargs.setdefault("by_alias", True) # will not be needed in Pydantic v3
28
+ return super().model_dump(**kwargs)
29
+
30
+ def model_dump_json(self, **kwargs) -> str:
31
+ """Dump models as they were in the metadata store."""
32
+ kwargs.setdefault("exclude_defaults", True)
33
+ kwargs.setdefault("by_alias", True) # will not be needed in Pydantic v3
34
+ return super().model_dump_json(**kwargs)
35
+
36
+
37
+ class InputDatasetObject(InputDatasetBaseModel):
38
+ """Input dataset object validator for a single file."""
39
+
40
+ bucket: str
41
+ object_key: str
42
+ tag: str | None = None
43
+
44
+
45
+ class InputDatasetFilePointer(InputDatasetBaseModel):
46
+ """Wrapper for InputDatasetObject files."""
47
+
48
+ file_pointer: InputDatasetObject = Field(alias="__file__")
49
+
50
+
51
+ class InputDatasetParameterValue(InputDatasetBaseModel):
52
+ """Input dataset parameter value validator."""
53
+
54
+ parameter_value_id: int
55
+ # parameter_value: Json[InputDatasetFilePointer] | Json[Any] # will work in gqlclient v2
56
+ parameter_value: Json[Any]
57
+ parameter_value_start_date: Annotated[
58
+ datetime, Field(default=datetime(1, 1, 1)), PlainSerializer(lambda x: x.isoformat())
59
+ ]
60
+
61
+ @field_validator("parameter_value", mode="after")
62
+ @classmethod
63
+ def validate_parameter_value(cls, param_val):
64
+ """Decode and provide additional validation for parameter_value types."""
65
+ match param_val:
66
+ case {"__file__": _}:
67
+ return InputDatasetFilePointer.model_validate(param_val)
68
+ case _:
69
+ return param_val
70
+
71
+ @field_serializer("parameter_value")
72
+ def serialize_parameter_value(self, param_val):
73
+ """Serialize the parameter_value types."""
74
+ if isinstance(param_val, InputDatasetBaseModel):
75
+ return json.dumps(param_val.model_dump())
76
+ return json.dumps(param_val)
77
+
78
+
79
+ class InputDatasetParameter(InputDatasetBaseModel):
80
+ """Parsing of the inputDatasetPartDocument that is relevant for parameters."""
81
+
82
+ parameter_name: str
83
+ parameter_values: list[InputDatasetParameterValue]
84
+
85
+ @property
86
+ def input_dataset_objects(self) -> list[InputDatasetObject]:
87
+ """Find and return list of InputDatasetObjects."""
88
+ object_list = []
89
+ for param in self.parameter_values:
90
+ if isinstance(param.parameter_value, InputDatasetFilePointer):
91
+ object_list.append(param.parameter_value.file_pointer)
92
+ return object_list
93
+
94
+
95
+ class InputDatasetFrames(InputDatasetBaseModel):
96
+ """Parsing of the inputDatasetPartDocument that is relevant for frames."""
97
+
98
+ bucket: str
99
+ object_keys: list[str] = Field(alias="object_keys") # not camel case in metadata store
100
+
101
+ @property
102
+ def input_dataset_objects(self) -> list[InputDatasetObject]:
103
+ """Convert a single bucket and a list of object_keys list into a list of InputDatasetObjects."""
104
+ object_list = []
105
+ for frame in self.object_keys:
106
+ object_list.append(InputDatasetObject(bucket=self.bucket, object_key=frame))
107
+ return object_list
108
+
109
+
110
+ class InputDatasetPartDocumentList(InputDatasetBaseModel):
111
+ """List of either InputDatasetFrames or InputDatasetParameter objects."""
112
+
113
+ doc_list: list[InputDatasetFrames] | list[InputDatasetParameter] = Field(alias="doc_list")
@@ -1,14 +1,23 @@
1
1
  """Base class for parameter-parsing object."""
2
2
  import logging
3
+ from contextlib import contextmanager
3
4
  from datetime import datetime
5
+ from pathlib import Path
4
6
  from typing import Any
7
+ from typing import Callable
5
8
  from typing import Literal
6
9
 
7
10
  import numpy as np
8
11
  import scipy.interpolate as spi
9
- from astropy.io import fits
10
12
 
11
- from dkist_processing_common.tasks.mixin.input_dataset import InputDatasetParameterValue
13
+ from dkist_processing_common._util.scratch import WorkflowFileSystem
14
+ from dkist_processing_common.codecs.array import array_decoder
15
+ from dkist_processing_common.codecs.basemodel import basemodel_decoder
16
+ from dkist_processing_common.codecs.fits import fits_array_decoder
17
+ from dkist_processing_common.models.input_dataset import InputDatasetFilePointer
18
+ from dkist_processing_common.models.input_dataset import InputDatasetPartDocumentList
19
+ from dkist_processing_common.models.tags import Tag
20
+
12
21
 
13
22
  logger = logging.getLogger(__name__)
14
23
 
@@ -24,9 +33,9 @@ class ParameterBase:
24
33
 
25
34
  To use in an instrument pipeline a subclass is required. Here's a simple, but complete example::
26
35
 
27
- class InstParameters(ParameterBase)
28
- def __init__(self, input_dataset_parameters, some_other_parameter):
29
- super().__init__(input_dataset_parameters)
36
+ class InstParameters(ParameterBase):
37
+ def __init__(self, scratch, some_other_parameters):
38
+ super().__init__(scratch=scratch)
30
39
  self._thing = self._some_function(some_other_parameters)
31
40
 
32
41
  @property
@@ -34,7 +43,7 @@ class ParameterBase:
34
43
  return self._find_most_recent_past_value("some_parameter_name")
35
44
 
36
45
  @property
37
- def complicate_parameter(self):
46
+ def complicated_parameter(self):
38
47
  return self._some_complicated_parsing_function("complicated_parameter_name", another_argument)
39
48
 
40
49
 
@@ -55,15 +64,16 @@ class ParameterBase:
55
64
  workflow_version=workflow_version,
56
65
  )
57
66
 
58
- self.parameters = InstParameters(self.input_dataset_parameters) #<------ This is the important line
67
+ self.parameters = InstParameters(scratch=self.scratch) #<------ This is the important line
59
68
 
60
- Note that the first argument to the ConstantsSubclass with *always* be self.input_dataset_parameters, but
61
- additional argument can be passed if the subclass requires them.
69
+ ParameterBase needs the task scratch in order to read the parameters document written at input dataset
70
+ transfer. Note that the first argument to the ConstantsSubclass will *always* be scratch, but additional
71
+ arguments can be passed if the subclass requires them.
62
72
 
63
73
  Parameters
64
74
  ----------
65
- input_dataset_parameters
66
- The input parameters
75
+ scratch
76
+ The task scratch WorkflowFileSystem instance
67
77
 
68
78
  obs_ip_start_time
69
79
  A string containing the start date of the Observe IP task type frames. Must be in isoformat.
@@ -74,25 +84,53 @@ class ParameterBase:
74
84
 
75
85
  def __init__(
76
86
  self,
77
- input_dataset_parameters: dict[str, list[InputDatasetParameterValue]],
87
+ scratch: WorkflowFileSystem,
78
88
  obs_ip_start_time: str | None = None,
79
89
  **kwargs,
80
90
  ):
91
+ self.scratch = scratch
92
+ input_dataset_parameter_model = self._get_parameters_doc_from_file()
93
+ input_dataset_parameters = {}
94
+ if input_dataset_parameter_model is not None:
95
+ input_dataset_parameters = {
96
+ p.parameter_name: p.parameter_values for p in input_dataset_parameter_model.doc_list
97
+ }
81
98
  self.input_dataset_parameters = input_dataset_parameters
99
+
82
100
  if obs_ip_start_time is not None:
83
101
  # Specifically `not None` because we want to error normally on badly formatted strings (including "").
84
102
  self._obs_ip_start_datetime = datetime.fromisoformat(obs_ip_start_time)
85
103
  else:
86
104
  logger.info(
87
105
  "WARNING: "
88
- "The task containing this parameters object did not provide an obs ip start time. "
89
- "This really only makes sense for Parsing tasks."
106
+ "The task containing this parameters object did not provide an obs ip start time, "
107
+ "which really only makes sense for Parsing tasks."
90
108
  )
91
109
 
92
110
  for parent_class in self.__class__.__bases__:
93
111
  if hasattr(parent_class, "is_param_mixin"):
94
112
  parent_class.__init__(self, **kwargs)
95
113
 
114
+ def _read_parameter_file(
115
+ self, tag: str, decoder: Callable[[Path], Any], **decoder_kwargs
116
+ ) -> Any:
117
+ """Read any file in the task scratch instance."""
118
+ paths = list(self.scratch.find_all(tags=tag))
119
+ if len(paths) == 0:
120
+ logger.info(f"WARNING: There is no parameter file for {tag = }")
121
+ if len(paths) == 1:
122
+ return decoder(paths[0], **decoder_kwargs)
123
+ if len(paths) > 1:
124
+ raise ValueError(f"There is more than one parameter file for {tag = }: {paths}")
125
+
126
+ def _get_parameters_doc_from_file(self) -> InputDatasetPartDocumentList:
127
+ """Get parameters doc saved at the TransferL0Data task."""
128
+ tag = Tag.input_dataset_parameters()
129
+ parameters_from_file = self._read_parameter_file(
130
+ tag=tag, decoder=basemodel_decoder, model=InputDatasetPartDocumentList
131
+ )
132
+ return parameters_from_file
133
+
96
134
  def _find_most_recent_past_value(
97
135
  self,
98
136
  parameter_name: str,
@@ -113,20 +151,19 @@ class ParameterBase:
113
151
  )
114
152
  return result
115
153
 
116
- @staticmethod
117
- def _load_param_value_from_fits(param_dict: dict, hdu: int = 0) -> np.ndarray:
118
- """Load a numpy array from a parameter pointing to a FITS file."""
119
- file_path = param_dict["param_path"]
120
-
121
- hdul = fits.open(file_path)
122
- return hdul[hdu].data
123
-
124
- @staticmethod
125
- def _load_param_value_from_numpy_save(param_dict: dict) -> np.ndarray:
126
- """Return the data associated with a parameter file saved in numpy format."""
127
- file_path = param_dict["param_path"]
128
- result = np.load(file_path)
129
- return result
154
+ def _load_param_value_from_fits(
155
+ self, param_obj: InputDatasetFilePointer, hdu: int = 0
156
+ ) -> np.ndarray:
157
+ """Return the data associated with a tagged parameter file saved in FITS format."""
158
+ tag = param_obj.file_pointer.tag
159
+ param_value = self._read_parameter_file(tag=tag, decoder=fits_array_decoder, hdu=hdu)
160
+ return param_value
161
+
162
+ def _load_param_value_from_numpy_save(self, param_obj: InputDatasetFilePointer) -> np.ndarray:
163
+ """Return the data associated with a tagged parameter file saved in numpy format."""
164
+ tag = param_obj.file_pointer.tag
165
+ param_value = self._read_parameter_file(tag=tag, decoder=array_decoder)
166
+ return param_value
130
167
 
131
168
 
132
169
  class _ParamMixinBase:
@@ -31,3 +31,4 @@ class L1QualityFitsAccess(L1FitsAccess):
31
31
  self.light_level: float = self.header["LIGHTLVL"]
32
32
  self.health_status: str = self.header["DSHEALTH"]
33
33
  self.ao_status: int = self.header.get("AO_LOCK", None)
34
+ self.num_out_of_bounds_ao_values: int = self.header.get("OOBSHIFT", None)
@@ -210,16 +210,19 @@ class MetadataStoreMixin:
210
210
  self, part_type: Literal["observe_frames", "calibration_frames", "parameters"]
211
211
  ) -> InputDatasetPartResponse:
212
212
  """Get the input dataset part by input dataset part type name."""
213
- part_type_dict = {}
213
+ part_types_found = set()
214
+ input_dataset_part = None
214
215
  parts = (
215
216
  self.metadata_store_input_dataset_recipe_run.recipeInstance.inputDataset.inputDatasetInputDatasetParts
216
217
  )
217
218
  for part in parts:
218
219
  part_type_name = part.inputDatasetPart.inputDatasetPartType.inputDatasetPartTypeName
219
- if part_type_name in part_type_dict.keys():
220
+ if part_type_name in part_types_found:
220
221
  raise ValueError(f"Multiple input dataset parts found for {part_type_name=}.")
221
- part_type_dict[part_type_name] = part.inputDatasetPart
222
- return part_type_dict.get(part_type)
222
+ part_types_found.add(part_type_name)
223
+ if part_type_name == part_type:
224
+ input_dataset_part = part.inputDatasetPart
225
+ return input_dataset_part
223
226
 
224
227
  @property
225
228
  def metadata_store_input_dataset_observe_frames(self) -> InputDatasetPartResponse:
@@ -20,6 +20,7 @@ from dkist_processing_pac.fitter.fitting_core import compare_I
20
20
  from dkist_processing_pac.fitter.polcal_fitter import PolcalFitter
21
21
  from pandas import DataFrame
22
22
 
23
+ from dkist_processing_common.models.fried_parameter import r0_valid
23
24
  from dkist_processing_common.models.metric_code import MetricCode
24
25
  from dkist_processing_common.models.quality import EfficiencyHistograms
25
26
  from dkist_processing_common.models.quality import ModulationMatrixHistograms
@@ -203,7 +204,7 @@ class _SimplePlotQualityMixin:
203
204
  return warnings
204
205
 
205
206
  def quality_store_ao_status_and_fried_parameter(
206
- self, datetimes: list[str], values: list[list[bool, float]]
207
+ self, datetimes: list[str], values: list[list[bool | float]]
207
208
  ):
208
209
  """
209
210
  Collect and store datetime / value pairs for the boolean AO status and Fried parameter.
@@ -213,23 +214,27 @@ class _SimplePlotQualityMixin:
213
214
  Because of how L1Metric.has_metric works, empty lists will not be passed to this method.
214
215
  However, because of how L1Metric.store_metric works, one or both values can be None.
215
216
  """
216
- ao_values = [value[0] for value in values]
217
- ao_not_none = [ao for ao in ao_values if ao is not None]
217
+ ao_lock_values = [value[0] for value in values]
218
+ ao_not_none = [ao for ao in ao_lock_values if ao is not None]
218
219
  if len(ao_not_none) != 0:
219
220
  self._record_values(values=ao_not_none, tags=Tag.quality(MetricCode.ao_status))
220
221
  fried_values = [value[1] for value in values]
221
- fried_values_not_none_ao_locked = [
222
- r0 for ao, r0 in zip(ao_values, fried_values) if (ao is True) and (r0 is not None)
223
- ]
224
- datetimes_fried_not_none_ao_locked = [
225
- dt
226
- for dt, ao, r0 in zip(datetimes, ao_values, fried_values)
227
- if (ao is True) and (r0 is not None)
228
- ]
229
- if len(fried_values_not_none_ao_locked) != 0:
222
+ ao_oob_values = [value[2] for value in values]
223
+ fried_values_to_plot = []
224
+ datetimes_to_plot = []
225
+ # For each set of input data, check if the r0 is considered valid based on all data
226
+ for i in range(len(fried_values)):
227
+ if r0_valid(
228
+ r0=fried_values[i],
229
+ ao_lock=ao_lock_values[i],
230
+ num_out_of_bounds_ao_values=ao_oob_values[i],
231
+ ):
232
+ fried_values_to_plot.append(fried_values[i])
233
+ datetimes_to_plot.append(datetimes[i])
234
+ if len(fried_values_to_plot) != 0:
230
235
  self._record_2d_plot_values(
231
- x_values=datetimes_fried_not_none_ao_locked,
232
- y_values=fried_values_not_none_ao_locked,
236
+ x_values=datetimes_to_plot,
237
+ y_values=fried_values_to_plot,
233
238
  tags=Tag.quality(MetricCode.fried_parameter),
234
239
  )
235
240
 
@@ -296,7 +296,7 @@ class QualityL1Metrics(WorkflowTaskBase, QualityMixin):
296
296
  L1Metric(storage_method=self.quality_store_health_status, value_source="health_status"),
297
297
  L1Metric(
298
298
  storage_method=self.quality_store_ao_status_and_fried_parameter,
299
- value_source=["ao_status", "fried_parameter"],
299
+ value_source=["ao_status", "fried_parameter", "num_out_of_bounds_ao_values"],
300
300
  ),
301
301
  ]
302
302