dkist-processing-common 11.7.0rc3__py3-none-any.whl → 11.9.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 (31) hide show
  1. dkist_processing_common/config.py +28 -6
  2. dkist_processing_common/models/constants.py +12 -36
  3. dkist_processing_common/models/graphql.py +0 -33
  4. dkist_processing_common/parsers/observing_program_id_bud.py +24 -0
  5. dkist_processing_common/tasks/l1_output_data.py +38 -32
  6. dkist_processing_common/tasks/mixin/globus.py +23 -26
  7. dkist_processing_common/tasks/mixin/metadata_store.py +0 -49
  8. dkist_processing_common/tasks/mixin/object_store.py +21 -0
  9. dkist_processing_common/tasks/mixin/quality/_metrics.py +4 -6
  10. dkist_processing_common/tasks/parse_l0_input_data.py +9 -30
  11. dkist_processing_common/tasks/trial_catalog.py +49 -1
  12. dkist_processing_common/tasks/trial_output_data.py +1 -1
  13. dkist_processing_common/tests/mock_metadata_store.py +39 -4
  14. dkist_processing_common/tests/test_input_dataset.py +1 -37
  15. dkist_processing_common/tests/test_parse_l0_input_data.py +9 -3
  16. dkist_processing_common/tests/test_publish_catalog_messages.py +0 -21
  17. dkist_processing_common/tests/test_quality_mixin.py +11 -3
  18. dkist_processing_common/tests/test_stems.py +35 -0
  19. dkist_processing_common/tests/test_submit_dataset_metadata.py +1 -5
  20. dkist_processing_common/tests/test_trial_catalog.py +72 -2
  21. dkist_processing_common/tests/test_trial_output_data.py +1 -2
  22. {dkist_processing_common-11.7.0rc3.dist-info → dkist_processing_common-11.9.1.dist-info}/METADATA +17 -13
  23. {dkist_processing_common-11.7.0rc3.dist-info → dkist_processing_common-11.9.1.dist-info}/RECORD +25 -30
  24. changelog/267.feature.1.rst +0 -1
  25. changelog/267.feature.2.rst +0 -1
  26. changelog/267.feature.rst +0 -1
  27. changelog/267.misc.rst +0 -1
  28. changelog/267.removal.1.rst +0 -2
  29. changelog/267.removal.rst +0 -1
  30. {dkist_processing_common-11.7.0rc3.dist-info → dkist_processing_common-11.9.1.dist-info}/WHEEL +0 -0
  31. {dkist_processing_common-11.7.0rc3.dist-info → dkist_processing_common-11.9.1.dist-info}/top_level.txt +0 -0
@@ -37,7 +37,9 @@ from dkist_processing_common.models.task_name import TaskName
37
37
  from dkist_processing_common.parsers.average_bud import TaskAverageBud
38
38
  from dkist_processing_common.parsers.experiment_id_bud import ContributingExperimentIdsBud
39
39
  from dkist_processing_common.parsers.experiment_id_bud import ExperimentIdBud
40
- from dkist_processing_common.parsers.id_bud import TaskContributingIdsBud
40
+ from dkist_processing_common.parsers.observing_program_id_bud import (
41
+ TaskContributingObservingProgramExecutionIdsBud,
42
+ )
41
43
  from dkist_processing_common.parsers.proposal_id_bud import ContributingProposalIdsBud
42
44
  from dkist_processing_common.parsers.proposal_id_bud import ProposalIdBud
43
45
  from dkist_processing_common.parsers.task import parse_header_ip_task_with_gains
@@ -88,27 +90,19 @@ def dataset_extra_bud_factory() -> list[S]:
88
90
  constant_name=BudName.hls_version,
89
91
  metadata_key=MetadataKey.hls_version,
90
92
  ),
91
- TaskContributingIdsBud(
92
- constant_name=BudName.dark_observing_program_execution_id,
93
- metadata_key=MetadataKey.observing_program_execution_id,
93
+ TaskContributingObservingProgramExecutionIdsBud(
94
+ constant_name=BudName.dark_observing_program_execution_ids,
94
95
  ip_task_types=TaskName.dark,
95
96
  ),
96
- TaskContributingIdsBud(
97
- constant_name=BudName.solar_gain_observing_program_execution_id,
98
- metadata_key=MetadataKey.observing_program_execution_id,
97
+ TaskContributingObservingProgramExecutionIdsBud(
98
+ constant_name=BudName.solar_gain_observing_program_execution_ids,
99
99
  ip_task_types=TaskName.solar_gain,
100
100
  task_type_parsing_function=parse_header_ip_task_with_gains,
101
101
  ),
102
- TaskContributingIdsBud(
103
- constant_name=BudName.polcal_observing_program_execution_id,
104
- metadata_key=MetadataKey.observing_program_execution_id,
102
+ TaskContributingObservingProgramExecutionIdsBud(
103
+ constant_name=BudName.polcal_observing_program_execution_ids,
105
104
  ip_task_types=TaskName.polcal,
106
105
  ),
107
- TaskUniqueBud(
108
- constant_name=BudName.dark_num_raw_frames_per_fpa,
109
- metadata_key=MetadataKey.num_raw_frames_per_fpa,
110
- ip_task_types=TaskName.dark,
111
- ),
112
106
  TaskUniqueBud(
113
107
  constant_name=BudName.solar_gain_num_raw_frames_per_fpa,
114
108
  metadata_key=MetadataKey.num_raw_frames_per_fpa,
@@ -120,11 +114,6 @@ def dataset_extra_bud_factory() -> list[S]:
120
114
  metadata_key=MetadataKey.num_raw_frames_per_fpa,
121
115
  ip_task_types=TaskName.polcal,
122
116
  ),
123
- TaskUniqueBud(
124
- constant_name=BudName.dark_telescope_tracking_mode,
125
- metadata_key=MetadataKey.telescope_tracking_mode,
126
- ip_task_types=TaskName.dark,
127
- ),
128
117
  TaskUniqueBud(
129
118
  constant_name=BudName.solar_gain_telescope_tracking_mode,
130
119
  metadata_key=MetadataKey.telescope_tracking_mode,
@@ -136,11 +125,6 @@ def dataset_extra_bud_factory() -> list[S]:
136
125
  metadata_key=MetadataKey.telescope_tracking_mode,
137
126
  ip_task_types=TaskName.polcal,
138
127
  ),
139
- TaskUniqueBud(
140
- constant_name=BudName.dark_coude_table_tracking_mode,
141
- metadata_key=MetadataKey.coude_table_tracking_mode,
142
- ip_task_types=TaskName.dark,
143
- ),
144
128
  TaskUniqueBud(
145
129
  constant_name=BudName.solar_gain_coude_table_tracking_mode,
146
130
  metadata_key=MetadataKey.coude_table_tracking_mode,
@@ -152,11 +136,6 @@ def dataset_extra_bud_factory() -> list[S]:
152
136
  metadata_key=MetadataKey.coude_table_tracking_mode,
153
137
  ip_task_types=TaskName.polcal,
154
138
  ),
155
- TaskUniqueBud(
156
- constant_name=BudName.dark_telescope_scanning_mode,
157
- metadata_key=MetadataKey.telescope_scanning_mode,
158
- ip_task_types=TaskName.dark,
159
- ),
160
139
  TaskUniqueBud(
161
140
  constant_name=BudName.solar_gain_telescope_scanning_mode,
162
141
  metadata_key=MetadataKey.telescope_scanning_mode,
@@ -5,15 +5,18 @@ import logging
5
5
  from datetime import datetime
6
6
  from itertools import chain
7
7
  from pathlib import Path
8
+ from typing import Any
8
9
  from typing import Generator
9
10
  from uuid import uuid4
10
11
 
11
12
  from dkist_processing_common.codecs.asdf import asdf_fileobj_encoder
13
+ from dkist_processing_common.codecs.basemodel import basemodel_decoder
12
14
  from dkist_processing_common.codecs.fits import fits_access_decoder
13
15
  from dkist_processing_common.codecs.json import json_encoder
14
16
  from dkist_processing_common.codecs.path import path_decoder
15
17
  from dkist_processing_common.codecs.quality import quality_data_decoder
16
18
  from dkist_processing_common.models.fits_access import FitsAccessBase
19
+ from dkist_processing_common.models.input_dataset import InputDatasetPartDocumentList
17
20
  from dkist_processing_common.models.tags import Tag
18
21
  from dkist_processing_common.tasks.output_data_base import OutputDataBase
19
22
 
@@ -27,6 +30,7 @@ INVENTORY_EXTRA_INSTALLED = False
27
30
  try:
28
31
  from dkist_inventory.inventory import generate_asdf_filename
29
32
  from dkist_inventory.inventory import generate_inventory_from_frame_inventory
33
+ from dkist_inventory.inventory import generate_quality_report_filename
30
34
 
31
35
  INVENTORY_EXTRA_INSTALLED = True
32
36
  except ModuleNotFoundError:
@@ -138,11 +142,15 @@ class CreateTrialAsdf(OutputDataBase):
138
142
 
139
143
  def run(self) -> None:
140
144
  """Generate an ASDF file simulating the ASDF file that would be produced when cataloging the dataset."""
145
+ with self.telemetry_span("Collate input dataset parameters"):
146
+ parameters = self.parse_input_dataset_parameters()
147
+
141
148
  with self.telemetry_span("Generate ASDF tree"):
142
149
  tree = asdf_tree_from_filenames(
143
150
  filenames=self.absolute_output_frame_paths,
144
151
  hdu=1, # compressed
145
152
  relative_to=self.scratch.workflow_base_path,
153
+ parameters=parameters,
146
154
  )
147
155
 
148
156
  trial_history = [
@@ -169,6 +177,37 @@ class CreateTrialAsdf(OutputDataBase):
169
177
  ),
170
178
  )
171
179
 
180
+ def parse_input_dataset_parameters(self) -> list[dict[str, Any]]:
181
+ """
182
+ Return the parameters associated with the dataset.
183
+
184
+ Returns
185
+ -------
186
+ list[dict[str, Any]]
187
+ A list of dictionaries, each containing a parameter name and its values.
188
+
189
+ Raises
190
+ ------
191
+ ValueError
192
+ If there is not exactly one ``InputDatasetPartDocumentList`` found.
193
+ """
194
+ part_docs_iter = self.read(
195
+ tags=Tag.input_dataset_parameters(),
196
+ decoder=basemodel_decoder,
197
+ model=InputDatasetPartDocumentList,
198
+ )
199
+ docs = list(part_docs_iter)
200
+
201
+ if not docs:
202
+ logger.warning("No parameter list decoded from files")
203
+ return []
204
+
205
+ if len(docs) > 1:
206
+ raise ValueError(f"Expected 1 parameter list, found {len(docs)}")
207
+
208
+ parameters = docs[0].model_dump(by_alias=True).get("doc_list", [])
209
+ return parameters
210
+
172
211
 
173
212
  class CreateTrialQualityReport(OutputDataBase):
174
213
  """
@@ -186,6 +225,13 @@ class CreateTrialQualityReport(OutputDataBase):
186
225
  f" but the required dependencies were not found."
187
226
  )
188
227
 
228
+ if not INVENTORY_EXTRA_INSTALLED:
229
+ raise ModuleNotFoundError(
230
+ f"{self.__class__.__name__} Task requires the dkist-inventory package "
231
+ f"(e.g. via an 'inventory' pip_extra on dkist_processing_core.Workflow().add_node())"
232
+ f" but the required dependencies were not found."
233
+ )
234
+
189
235
  def run(self) -> None:
190
236
  """Generate the quality report for the dataset."""
191
237
  self.create_trial_quality_report()
@@ -207,5 +253,7 @@ class CreateTrialQualityReport(OutputDataBase):
207
253
  self.write(
208
254
  quality_report,
209
255
  tags=[Tag.output(), Tag.quality_report()],
210
- relative_path=f"{self.constants.dataset_id}_quality_report.pdf",
256
+ relative_path=generate_quality_report_filename(
257
+ dataset_id=self.constants.dataset_id
258
+ ),
211
259
  )
@@ -100,7 +100,7 @@ class TransferTrialData(TransferDataBase, GlobusMixin):
100
100
  tag_list = []
101
101
  tag_list += [[Tag.output(), Tag.dataset_inventory()]]
102
102
  tag_list += [[Tag.output(), Tag.asdf()]]
103
- tag_list += [[Tag.quality_data()]] # quality data is not tagged as OUTPUT
103
+ tag_list += [[Tag.output(), Tag.quality_data()]]
104
104
  tag_list += [[Tag.output(), Tag.quality_report()]]
105
105
  tag_list += [[Tag.output(), Tag.movie()]]
106
106
  return tag_list
@@ -6,6 +6,7 @@ import json
6
6
  from abc import ABC
7
7
  from abc import abstractmethod
8
8
  from datetime import datetime
9
+ from datetime import timedelta
9
10
  from pathlib import Path
10
11
  from uuid import uuid4
11
12
 
@@ -134,10 +135,6 @@ class InputDatasetRecipeRunResponseMapping(ResponseMapping):
134
135
  return Unset
135
136
 
136
137
 
137
- class QualityResponseMapping(ResponseMapping):
138
- pass # TODO
139
-
140
-
141
138
  def make_default_recipe_run_status_response() -> RecipeRunStatusResponse:
142
139
  return RecipeRunStatusResponse(recipeRunStatusId=1)
143
140
 
@@ -234,3 +231,41 @@ def fake_gql_client():
234
231
  Convenience fixture for default mock GQL client. To customize, use fake_gql_client_factory.
235
232
  """
236
233
  return fake_gql_client_factory()
234
+
235
+
236
+ def input_dataset_parameters_part_factory(
237
+ parameter_count: int = 1,
238
+ parameter_value_count: int = 1,
239
+ has_date: bool = False,
240
+ has_file: bool = False,
241
+ ) -> list[dict]:
242
+ """Create a mock InputDatasetPartDocumentList with parameters."""
243
+ result = [
244
+ {
245
+ "parameterName": uuid4().hex[:6],
246
+ "parameterValues": [
247
+ {"parameterValueId": i, "parameterValue": json.dumps(uuid4().hex)}
248
+ for i in range(parameter_value_count)
249
+ ],
250
+ }
251
+ for _ in range(parameter_count)
252
+ ]
253
+ if has_date:
254
+ base = datetime(2018, 9, 14, 0, 0, 0) # This date is before any possible start dates
255
+ for parameter_index, data in enumerate(result):
256
+ for item in data["parameterValues"]:
257
+ dt = base + timedelta(days=parameter_index)
258
+ item["parameterValueStartDate"] = dt.isoformat()
259
+ if has_file:
260
+ for data in result:
261
+ param_list = data["parameterValues"]
262
+ for item in param_list:
263
+ item["parameterValue"] = json.dumps(
264
+ {
265
+ "__file__": {
266
+ "bucket": "data",
267
+ "objectKey": f"parameters/{data['parameterName']}/{uuid4().hex}.dat",
268
+ }
269
+ }
270
+ )
271
+ return result
@@ -1,5 +1,4 @@
1
1
  import json
2
- from datetime import datetime
3
2
  from typing import Any
4
3
  from uuid import uuid4
5
4
 
@@ -8,6 +7,7 @@ import pytest
8
7
  from dkist_processing_common.codecs.basemodel import basemodel_decoder
9
8
  from dkist_processing_common.models.input_dataset import InputDatasetPartDocumentList
10
9
  from dkist_processing_common.models.tags import Tag
10
+ from dkist_processing_common.tests.mock_metadata_store import input_dataset_parameters_part_factory
11
11
 
12
12
 
13
13
  def input_dataset_frames_part_factory(bucket_count: int = 1) -> list[dict]:
@@ -25,42 +25,6 @@ def flatten_frame_parts(frame_parts: list[dict]) -> list[tuple[str, str]]:
25
25
  return result
26
26
 
27
27
 
28
- def input_dataset_parameters_part_factory(
29
- parameter_count: int = 1,
30
- parameter_value_count: int = 1,
31
- has_date: bool = False,
32
- has_file: bool = False,
33
- ) -> list[dict]:
34
- result = [
35
- {
36
- "parameterName": uuid4().hex[:6],
37
- "parameterValues": [
38
- {"parameterValueId": i, "parameterValue": json.dumps(uuid4().hex)}
39
- for i in range(parameter_value_count)
40
- ],
41
- }
42
- for _ in range(parameter_count)
43
- ]
44
- if has_date:
45
- for data in result:
46
- param_list = data["parameterValues"]
47
- for item in param_list:
48
- item["parameterValueStartDate"] = datetime(2022, 9, 14).isoformat()
49
- if has_file:
50
- for data in result:
51
- param_list = data["parameterValues"]
52
- for item in param_list:
53
- item["parameterValue"] = json.dumps(
54
- {
55
- "__file__": {
56
- "bucket": "data",
57
- "objectKey": f"parameters/{data['parameterName']}/{uuid4().hex}.dat",
58
- }
59
- }
60
- )
61
- return result
62
-
63
-
64
28
  @pytest.mark.parametrize(
65
29
  "input_dataset_parts",
66
30
  [
@@ -260,7 +260,7 @@ def test_subclass_flowers(visp_parse_inputs_task, max_cs_step_time_sec):
260
260
  tag_pot, constant_pot = visp_parse_inputs_task.make_flower_pots()
261
261
 
262
262
  assert len(tag_pot.stems) == 1
263
- assert len(constant_pot.stems) == 64
263
+ assert len(constant_pot.stems) == 60
264
264
  all_flower_names = [StemName.modstate]
265
265
  assert sorted([f.stem_name for f in tag_pot.stems]) == sorted(all_flower_names)
266
266
  all_bud_names = [b.stem_name for b in default_constant_bud_factory()] + [BudName.num_modstates]
@@ -277,7 +277,7 @@ def test_dataset_extra_bud_factory(visp_parse_inputs_task, max_cs_step_time_sec)
277
277
  stem_names = [f.stem_name.value for f in constant_pot.stems]
278
278
  bud_name_base = [
279
279
  "DATE_BEGIN",
280
- "OBSERVING_PROGRAM_EXECUTION_ID",
280
+ "OBSERVING_PROGRAM_EXECUTION_IDS",
281
281
  "NUM_RAW_FRAMES_PER_FPA",
282
282
  "TELESCOPE_TRACKING_MODE",
283
283
  "COUDE_TABLE_TRACKING_MODE",
@@ -295,8 +295,14 @@ def test_dataset_extra_bud_factory(visp_parse_inputs_task, max_cs_step_time_sec)
295
295
  "GOS_LEVEL0_STATUS",
296
296
  ]
297
297
  for base in bud_name_base:
298
- assert "DARK_" + base in stem_names
299
298
  assert "SOLAR_GAIN_" + base in stem_names
299
+ if base not in [
300
+ "NUM_RAW_FRAMES_PER_FPA",
301
+ "TELESCOPE_TRACKING_MODE",
302
+ "COUDE_TABLE_TRACKING_MODE",
303
+ "TELESCOPE_SCANNING_MODE",
304
+ ]:
305
+ assert "DARK_" + base in stem_names
300
306
  if "GOS" not in base:
301
307
  assert "POLCAL_" + base in stem_names
302
308
 
@@ -57,24 +57,3 @@ def test_object_messages(publish_catalog_and_quality_messages_task):
57
57
  assert message.body.conversationId == str(task.recipe_run_id)
58
58
  assert message.body.objectType == object_type
59
59
  assert message.body.groupId == task.constants.dataset_id
60
-
61
-
62
- def test_quality_report_message(publish_catalog_and_quality_messages_task):
63
- """
64
- :Given: a PublishCatalogAndQualityMessages task
65
- :When: creating quality report message
66
- :Then: the attributes are correctly populated
67
- """
68
- # Given
69
- task, proposal_id = publish_catalog_and_quality_messages_task
70
- # When
71
- message = task.quality_report_message
72
- # Then
73
- assert isinstance(message, CreateQualityReportMessage)
74
- assert message.body.bucket == task.destination_bucket
75
- # objectName exists and can be evaluated as a valid path
76
- assert message.body.objectName
77
- _ = Path(message.body.objectName)
78
- assert message.body.datasetId == task.constants.dataset_id
79
- assert message.body.conversationId == str(task.recipe_run_id)
80
- assert message.body.incrementDatasetCatalogReceiptCount is True
@@ -1214,12 +1214,20 @@ def wavecal_weights(wavecal_input_wavelength) -> np.ndarray:
1214
1214
 
1215
1215
 
1216
1216
  @pytest.fixture(scope="session")
1217
- def wavecal_fit_result(wavecal_input_wavelength) -> FitResult:
1217
+ def wavecal_fit_result(wavecal_input_wavelength, wavecal_input_spectrum) -> FitResult:
1218
1218
  wavelength_params = WavelengthParameters(
1219
1219
  crpix=1, crval=10.0, dispersion=1, grating_constant=1, order=1, incident_light_angle=0
1220
1220
  )
1221
- minimizer_result = MinimizerResult(residual=np.random.random(wavecal_input_wavelength.size))
1222
- return FitResult(wavelength_parameters=wavelength_params, minimizer_result=minimizer_result)
1221
+
1222
+ residuals = np.random.random(wavecal_input_wavelength.size)
1223
+ residuals[-1] = np.nan
1224
+ minimizer_result = MinimizerResult(residual=residuals)
1225
+ return FitResult(
1226
+ wavelength_parameters=wavelength_params,
1227
+ minimizer_result=minimizer_result,
1228
+ input_wavelength_vector=wavecal_input_wavelength,
1229
+ input_spectrum=wavecal_input_spectrum,
1230
+ )
1223
1231
 
1224
1232
 
1225
1233
  @pytest.mark.parametrize(
@@ -19,6 +19,9 @@ from dkist_processing_common.parsers.experiment_id_bud import ExperimentIdBud
19
19
  from dkist_processing_common.parsers.id_bud import TaskContributingIdsBud
20
20
  from dkist_processing_common.parsers.near_bud import NearFloatBud
21
21
  from dkist_processing_common.parsers.near_bud import TaskNearFloatBud
22
+ from dkist_processing_common.parsers.observing_program_id_bud import (
23
+ TaskContributingObservingProgramExecutionIdsBud,
24
+ )
22
25
  from dkist_processing_common.parsers.proposal_id_bud import ContributingProposalIdsBud
23
26
  from dkist_processing_common.parsers.proposal_id_bud import ProposalIdBud
24
27
  from dkist_processing_common.parsers.retarder import RetarderNameBud
@@ -51,6 +54,7 @@ class FitsReaderMetadataKey(StrEnum):
51
54
  near_thing = "near"
52
55
  proposal_id = "ID___013"
53
56
  experiment_id = "ID___012"
57
+ observing_program_execution_id = "ID___008"
54
58
  ip_task_type = "DKIST004"
55
59
  ip_start_time = "DKIST011"
56
60
  fpa_exposure_time_ms = "XPOSURE"
@@ -77,6 +81,9 @@ class FitsReader(FitsAccessBase):
77
81
  self.name = name
78
82
  self.proposal_id: str = self.header.get(FitsReaderMetadataKey.proposal_id)
79
83
  self.experiment_id: str = self.header.get(FitsReaderMetadataKey.experiment_id)
84
+ self.observing_program_execution_id: str = self.header.get(
85
+ FitsReaderMetadataKey.observing_program_execution_id
86
+ )
80
87
  self.ip_task_type: str = self.header.get(FitsReaderMetadataKey.ip_task_type)
81
88
  self.ip_start_time: str = self.header.get(FitsReaderMetadataKey.ip_start_time)
82
89
  self.fpa_exposure_time_ms: float = self.header.get(
@@ -113,6 +120,7 @@ def basic_header_objs():
113
120
  "DKIST004": "observe",
114
121
  "ID___012": "experiment_id_1",
115
122
  "ID___013": "proposal_id_1",
123
+ "ID___008": "observing_program_execution_id_1",
116
124
  "XPOSURE": 0.0013000123,
117
125
  "TEXPOSUR": 10.0,
118
126
  "NSUMEXP": 3,
@@ -131,6 +139,7 @@ def basic_header_objs():
131
139
  "DKIST004": "observe",
132
140
  "ID___012": "experiment_id_1",
133
141
  "ID___013": "proposal_id_1",
142
+ "ID___008": "observing_program_execution_id_2",
134
143
  "XPOSURE": 0.0013000987,
135
144
  "TEXPOSUR": 10.0,
136
145
  "NSUMEXP": 3,
@@ -150,6 +159,7 @@ def basic_header_objs():
150
159
  "DKIST004": "dark",
151
160
  "ID___012": "experiment_id_2",
152
161
  "ID___013": "proposal_id_2",
162
+ "ID___008": "observing_program_execution_id_2",
153
163
  "XPOSURE": 12.345,
154
164
  "TEXPOSUR": 1.123456789,
155
165
  "NSUMEXP": 1,
@@ -170,6 +180,7 @@ def basic_header_objs():
170
180
  "DKIST004": "observe",
171
181
  "ID___012": "experiment_id_1",
172
182
  "ID___013": "proposal_id_1",
183
+ "ID___008": "observing_program_execution_id_1",
173
184
  "XPOSURE": 100.0,
174
185
  "TEXPOSUR": 11.0,
175
186
  "NSUMEXP": 4,
@@ -185,6 +196,7 @@ def basic_header_objs():
185
196
  {
186
197
  "DKIST004": "gain",
187
198
  "ID___013": "proposal_id_1",
199
+ "ID___008": "observing_program_execution_id_1",
188
200
  "id_key": 0,
189
201
  "constant": 6.28,
190
202
  "near": 1.23,
@@ -603,6 +615,29 @@ def test_task_contributing_ids_bud(basic_header_objs):
603
615
  assert sorted(list(petal[0].value)) == ["experiment_id_2"]
604
616
 
605
617
 
618
+ def test_task_contributing_observing_program_execution_ids_bud(basic_header_objs):
619
+ """
620
+ Given: A set of headers with observing program execution ID values for different tasks
621
+ When: Ingesting the headers with a TaskContributingObservingProgramExecutionIdsBud for a task type
622
+ Then: The Bud's petal is the observing program execution IDs for the that task type
623
+ """
624
+ bud = TaskContributingObservingProgramExecutionIdsBud(
625
+ constant_name="NOT_A_REAL_BUD",
626
+ ip_task_types=TaskName.observe,
627
+ )
628
+ assert bud.stem_name == "NOT_A_REAL_BUD"
629
+ for fo in basic_header_objs:
630
+ key = fo.name
631
+ bud.update(key, fo)
632
+
633
+ petal = list(bud.petals)
634
+ assert len(petal) == 1
635
+ assert sorted(list(petal[0].value)) == [
636
+ "observing_program_execution_id_1",
637
+ "observing_program_execution_id_2",
638
+ ]
639
+
640
+
606
641
  def test_exp_time_flower(basic_header_objs):
607
642
  """
608
643
  Given: A set of filepaths and associated headers with XPOSURE keywords
@@ -95,13 +95,10 @@ def test_submit_dataset_metadata(
95
95
  mocker.patch(
96
96
  "dkist_processing_common.tasks.mixin.metadata_store.GraphQLClient", new=fake_gql_client
97
97
  )
98
- # intercept these two GraphQLClient calls so they can be confirmed
98
+ # intercept this GraphQLClient call so it can be confirmed
99
99
  mocked_metadata_store_add_dataset_receipt_account = mocker.patch.object(
100
100
  metadata_store.MetadataStoreMixin, "metadata_store_add_dataset_receipt_account"
101
101
  )
102
- mocked_metadata_store_add_quality_data = mocker.patch.object(
103
- metadata_store.MetadataStoreMixin, "metadata_store_add_quality_data"
104
- )
105
102
  task = submit_dataset_metadata_task
106
103
 
107
104
  # When
@@ -109,4 +106,3 @@ def test_submit_dataset_metadata(
109
106
 
110
107
  # Then
111
108
  mocked_metadata_store_add_dataset_receipt_account.assert_called_once()
112
- mocked_metadata_store_add_quality_data.assert_called_once()
@@ -12,14 +12,30 @@ from sqids import Sqids
12
12
 
13
13
  from dkist_processing_common._util.scratch import WorkflowFileSystem
14
14
  from dkist_processing_common.codecs.asdf import asdf_decoder
15
+ from dkist_processing_common.codecs.basemodel import basemodel_encoder
15
16
  from dkist_processing_common.codecs.bytes import bytes_decoder
16
17
  from dkist_processing_common.codecs.fits import fits_hdulist_encoder
17
18
  from dkist_processing_common.codecs.json import json_decoder
18
19
  from dkist_processing_common.codecs.quality import quality_data_encoder
20
+ from dkist_processing_common.models.input_dataset import InputDatasetParameter
21
+ from dkist_processing_common.models.input_dataset import InputDatasetPartDocumentList
19
22
  from dkist_processing_common.models.tags import Tag
20
23
  from dkist_processing_common.tasks import CreateTrialAsdf
21
24
  from dkist_processing_common.tasks import CreateTrialDatasetInventory
22
25
  from dkist_processing_common.tasks import CreateTrialQualityReport
26
+ from dkist_processing_common.tests.mock_metadata_store import input_dataset_parameters_part_factory
27
+
28
+
29
+ @pytest.fixture()
30
+ def mock_input_dataset_parts() -> InputDatasetPartDocumentList:
31
+ """An InputDatasetPartDocumentList with two parameters, each with one value and a date."""
32
+ raw = input_dataset_parameters_part_factory(
33
+ parameter_count=2,
34
+ parameter_value_count=1,
35
+ has_date=True,
36
+ has_file=False,
37
+ )
38
+ return InputDatasetPartDocumentList.model_validate({"doc_list": raw})
23
39
 
24
40
 
25
41
  @pytest.fixture()
@@ -41,6 +57,24 @@ def scratch_with_l1_frames(recipe_run_id, tmp_path) -> WorkflowFileSystem:
41
57
  scratch.write(
42
58
  file_obj, tags=[Tag.output(), Tag.frame()], relative_path=f"{uuid4().hex}.dat"
43
59
  )
60
+
61
+ return scratch
62
+
63
+
64
+ @pytest.fixture()
65
+ def scratch_with_l1_frames_and_parameters(
66
+ scratch_with_l1_frames, mock_input_dataset_parts
67
+ ) -> WorkflowFileSystem:
68
+ """Scratch instance for a recipe run id with tagged L1 frames and input parameters."""
69
+ scratch = scratch_with_l1_frames
70
+
71
+ # Write validated Pydantic model bytes expected by InputDatasetPartDocumentList
72
+ file_obj = basemodel_encoder(mock_input_dataset_parts)
73
+ scratch.write(
74
+ file_obj,
75
+ tags=Tag.input_dataset_parameters(),
76
+ relative_path=f"{uuid4().hex}.json",
77
+ )
44
78
  return scratch
45
79
 
46
80
 
@@ -85,6 +119,22 @@ def create_trial_asdf_task(
85
119
  task._purge()
86
120
 
87
121
 
122
+ @pytest.fixture(scope="function")
123
+ def create_trial_asdf_task_with_params(
124
+ recipe_run_id, tmp_path, scratch_with_l1_frames_and_parameters, fake_constants_db
125
+ ) -> CreateTrialAsdf:
126
+ """An instance of CreateTrialAsdf with L1 frames and input parameters tagged in scratch."""
127
+ task = CreateTrialAsdf(
128
+ recipe_run_id=recipe_run_id,
129
+ workflow_name="trial_asdf",
130
+ workflow_version="trial_asdf_version",
131
+ )
132
+ task.scratch = scratch_with_l1_frames_and_parameters
133
+ task.constants._update(fake_constants_db)
134
+ yield task
135
+ task._purge()
136
+
137
+
88
138
  @pytest.fixture()
89
139
  def create_trial_quality_report_task(
90
140
  recipe_run_id, tmp_path, fake_constants_db
@@ -143,25 +193,32 @@ def test_create_trial_dataset_inventory(create_trial_dataset_inventory_task):
143
193
  assert len(inventory) > 20 # a bunch
144
194
 
145
195
 
146
- def test_create_trial_asdf(create_trial_asdf_task, recipe_run_id):
196
+ @pytest.mark.parametrize("with_params", [False, True], ids=["no_params", "with_params"])
197
+ def test_create_trial_asdf(with_params, request, recipe_run_id, mock_input_dataset_parts):
147
198
  """
148
199
  :Given: An instance of CreateTrialAsdf with L1 frames tagged in scratch
149
200
  :When: CreateTrialAsdf is run
150
201
  :Then: An asdf file for the dataset is tagged in scratch
151
202
  """
152
- task = create_trial_asdf_task
203
+ task = request.getfixturevalue(
204
+ "create_trial_asdf_task_with_params" if with_params else "create_trial_asdf_task"
205
+ )
153
206
  # When
154
207
  task()
208
+
155
209
  # Then
156
210
  asdf_tags = [Tag.output(), Tag.asdf()]
157
211
  filepaths = list(task.scratch.find_all(tags=asdf_tags))
158
212
  assert len(filepaths) == 1
159
213
  dataset_id = Sqids(min_length=6, alphabet=ascii_uppercase).encode([recipe_run_id])
160
214
  assert filepaths[0].name == f"INSTRUMENT_L1_20240416T160000_{dataset_id}_metadata.asdf"
215
+
161
216
  results = list(task.read(tags=asdf_tags, decoder=asdf_decoder))
162
217
  assert len(results) == 1
218
+
163
219
  tree = results[0]
164
220
  assert isinstance(tree, dict)
221
+
165
222
  for file_name in tree["dataset"].files.filenames:
166
223
  # This is a slightly better than check that `not Path(file_name).is_absolute()` because it confirms
167
224
  # we've correctly stripped the path of *all* parents (not just those that start at root).
@@ -169,6 +226,19 @@ def test_create_trial_asdf(create_trial_asdf_task, recipe_run_id):
169
226
  # `scratch.workflow_base_path`
170
227
  assert Path(file_name).name == file_name
171
228
 
229
+ # Only check parameters when present
230
+ ds = tree["dataset"]
231
+ assert "parameters" in ds.meta
232
+ parameters = ds.meta["parameters"]
233
+ assert isinstance(parameters, list)
234
+ if with_params:
235
+ assert parameters, f"ASDF tree must include input parameters: {parameters}"
236
+ assert len(parameters) == len(mock_input_dataset_parts.doc_list)
237
+ for param in parameters:
238
+ assert InputDatasetParameter.model_validate(param) in mock_input_dataset_parts.doc_list
239
+ else:
240
+ assert ds.meta["parameters"] == []
241
+
172
242
 
173
243
  def test_create_trial_quality_report(create_trial_quality_report_task):
174
244
  """
@@ -158,13 +158,12 @@ def complete_trial_output_task(
158
158
  task.write(asdf_file_obj, relative_path=asdf_file_name, tags=[Tag.output(), Tag.asdf()])
159
159
 
160
160
  # Write quality data
161
- # quality data is not tagged as OUTPUT
162
161
  quality_data_obj = uuid4().hex.encode("utf8")
163
162
  quality_data_name = "quality_data.json"
164
163
  task.write(
165
164
  quality_data_obj,
166
165
  relative_path=quality_data_name,
167
- tags=Tag.quality_data(),
166
+ tags=[Tag.output(), Tag.quality_data()],
168
167
  )
169
168
 
170
169
  # Write a quality report file