dkist-processing-common 10.5.4__py3-none-any.whl → 12.1.0rc1__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 (122) hide show
  1. changelog/280.misc.rst +1 -0
  2. changelog/282.feature.2.rst +2 -0
  3. changelog/282.feature.rst +2 -0
  4. changelog/284.feature.rst +1 -0
  5. changelog/285.feature.rst +2 -0
  6. changelog/285.misc.rst +2 -0
  7. changelog/286.feature.rst +2 -0
  8. changelog/287.misc.rst +1 -0
  9. dkist_processing_common/__init__.py +1 -0
  10. dkist_processing_common/_util/constants.py +1 -0
  11. dkist_processing_common/_util/graphql.py +1 -0
  12. dkist_processing_common/_util/scratch.py +9 -9
  13. dkist_processing_common/_util/tags.py +1 -0
  14. dkist_processing_common/codecs/array.py +20 -0
  15. dkist_processing_common/codecs/asdf.py +9 -3
  16. dkist_processing_common/codecs/basemodel.py +22 -0
  17. dkist_processing_common/codecs/bytes.py +1 -0
  18. dkist_processing_common/codecs/fits.py +37 -9
  19. dkist_processing_common/codecs/iobase.py +1 -0
  20. dkist_processing_common/codecs/json.py +1 -0
  21. dkist_processing_common/codecs/path.py +1 -0
  22. dkist_processing_common/codecs/quality.py +1 -1
  23. dkist_processing_common/codecs/str.py +1 -0
  24. dkist_processing_common/config.py +64 -25
  25. dkist_processing_common/manual.py +6 -8
  26. dkist_processing_common/models/constants.py +373 -37
  27. dkist_processing_common/models/dkist_location.py +27 -0
  28. dkist_processing_common/models/fits_access.py +48 -0
  29. dkist_processing_common/models/flower_pot.py +231 -9
  30. dkist_processing_common/models/fried_parameter.py +41 -0
  31. dkist_processing_common/models/graphql.py +66 -75
  32. dkist_processing_common/models/input_dataset.py +117 -0
  33. dkist_processing_common/models/message.py +1 -1
  34. dkist_processing_common/models/message_queue_binding.py +1 -1
  35. dkist_processing_common/models/metric_code.py +2 -0
  36. dkist_processing_common/models/parameters.py +65 -28
  37. dkist_processing_common/models/quality.py +50 -5
  38. dkist_processing_common/models/tags.py +23 -21
  39. dkist_processing_common/models/task_name.py +3 -2
  40. dkist_processing_common/models/telemetry.py +28 -0
  41. dkist_processing_common/models/wavelength.py +3 -1
  42. dkist_processing_common/parsers/average_bud.py +46 -0
  43. dkist_processing_common/parsers/cs_step.py +13 -12
  44. dkist_processing_common/parsers/dsps_repeat.py +6 -4
  45. dkist_processing_common/parsers/experiment_id_bud.py +12 -4
  46. dkist_processing_common/parsers/id_bud.py +42 -27
  47. dkist_processing_common/parsers/l0_fits_access.py +5 -3
  48. dkist_processing_common/parsers/l1_fits_access.py +51 -23
  49. dkist_processing_common/parsers/lookup_bud.py +125 -0
  50. dkist_processing_common/parsers/near_bud.py +21 -20
  51. dkist_processing_common/parsers/observing_program_id_bud.py +24 -0
  52. dkist_processing_common/parsers/proposal_id_bud.py +13 -5
  53. dkist_processing_common/parsers/quality.py +2 -0
  54. dkist_processing_common/parsers/retarder.py +32 -0
  55. dkist_processing_common/parsers/single_value_single_key_flower.py +6 -1
  56. dkist_processing_common/parsers/task.py +8 -6
  57. dkist_processing_common/parsers/time.py +178 -72
  58. dkist_processing_common/parsers/unique_bud.py +21 -22
  59. dkist_processing_common/parsers/wavelength.py +5 -3
  60. dkist_processing_common/tasks/__init__.py +3 -2
  61. dkist_processing_common/tasks/assemble_movie.py +4 -3
  62. dkist_processing_common/tasks/base.py +59 -60
  63. dkist_processing_common/tasks/l1_output_data.py +54 -53
  64. dkist_processing_common/tasks/mixin/globus.py +24 -27
  65. dkist_processing_common/tasks/mixin/interservice_bus.py +1 -0
  66. dkist_processing_common/tasks/mixin/metadata_store.py +108 -243
  67. dkist_processing_common/tasks/mixin/object_store.py +22 -0
  68. dkist_processing_common/tasks/mixin/quality/__init__.py +1 -0
  69. dkist_processing_common/tasks/mixin/quality/_base.py +8 -1
  70. dkist_processing_common/tasks/mixin/quality/_metrics.py +166 -14
  71. dkist_processing_common/tasks/output_data_base.py +4 -3
  72. dkist_processing_common/tasks/parse_l0_input_data.py +277 -15
  73. dkist_processing_common/tasks/quality_metrics.py +9 -9
  74. dkist_processing_common/tasks/teardown.py +7 -7
  75. dkist_processing_common/tasks/transfer_input_data.py +67 -69
  76. dkist_processing_common/tasks/trial_catalog.py +77 -17
  77. dkist_processing_common/tasks/trial_output_data.py +16 -17
  78. dkist_processing_common/tasks/write_l1.py +102 -72
  79. dkist_processing_common/tests/conftest.py +32 -173
  80. dkist_processing_common/tests/mock_metadata_store.py +271 -0
  81. dkist_processing_common/tests/test_assemble_movie.py +4 -4
  82. dkist_processing_common/tests/test_assemble_quality.py +32 -4
  83. dkist_processing_common/tests/test_base.py +5 -19
  84. dkist_processing_common/tests/test_codecs.py +103 -12
  85. dkist_processing_common/tests/test_constants.py +15 -0
  86. dkist_processing_common/tests/test_dkist_location.py +15 -0
  87. dkist_processing_common/tests/test_fits_access.py +56 -19
  88. dkist_processing_common/tests/test_flower_pot.py +147 -5
  89. dkist_processing_common/tests/test_fried_parameter.py +27 -0
  90. dkist_processing_common/tests/test_input_dataset.py +78 -361
  91. dkist_processing_common/tests/test_interservice_bus.py +1 -0
  92. dkist_processing_common/tests/test_interservice_bus_mixin.py +1 -1
  93. dkist_processing_common/tests/test_manual_processing.py +33 -0
  94. dkist_processing_common/tests/test_output_data_base.py +5 -7
  95. dkist_processing_common/tests/test_parameters.py +71 -22
  96. dkist_processing_common/tests/test_parse_l0_input_data.py +115 -32
  97. dkist_processing_common/tests/test_publish_catalog_messages.py +2 -24
  98. dkist_processing_common/tests/test_quality.py +1 -0
  99. dkist_processing_common/tests/test_quality_mixin.py +255 -23
  100. dkist_processing_common/tests/test_scratch.py +2 -1
  101. dkist_processing_common/tests/test_stems.py +511 -168
  102. dkist_processing_common/tests/test_submit_dataset_metadata.py +3 -7
  103. dkist_processing_common/tests/test_tags.py +1 -0
  104. dkist_processing_common/tests/test_task_name.py +1 -1
  105. dkist_processing_common/tests/test_task_parsing.py +17 -7
  106. dkist_processing_common/tests/test_teardown.py +28 -24
  107. dkist_processing_common/tests/test_transfer_input_data.py +270 -125
  108. dkist_processing_common/tests/test_transfer_l1_output_data.py +2 -3
  109. dkist_processing_common/tests/test_trial_catalog.py +83 -8
  110. dkist_processing_common/tests/test_trial_output_data.py +46 -73
  111. dkist_processing_common/tests/test_workflow_task_base.py +8 -10
  112. dkist_processing_common/tests/test_write_l1.py +298 -76
  113. dkist_processing_common-12.1.0rc1.dist-info/METADATA +265 -0
  114. dkist_processing_common-12.1.0rc1.dist-info/RECORD +134 -0
  115. {dkist_processing_common-10.5.4.dist-info → dkist_processing_common-12.1.0rc1.dist-info}/WHEEL +1 -1
  116. docs/conf.py +1 -0
  117. docs/index.rst +1 -1
  118. docs/landing_page.rst +13 -0
  119. dkist_processing_common/tasks/mixin/input_dataset.py +0 -166
  120. dkist_processing_common-10.5.4.dist-info/METADATA +0 -175
  121. dkist_processing_common-10.5.4.dist-info/RECORD +0 -112
  122. {dkist_processing_common-10.5.4.dist-info → dkist_processing_common-12.1.0rc1.dist-info}/top_level.txt +0 -0
@@ -3,13 +3,21 @@ import json
3
3
  from io import StringIO
4
4
  from typing import Any
5
5
 
6
+ import astropy.units as u
6
7
  import numpy as np
7
8
  import pandas
8
9
  import pytest
10
+ from lmfit.minimizer import MinimizerResult
11
+ from pydantic import ValidationError
12
+ from solar_wavelength_calibration.fitter.wavelength_fitter import FitResult
13
+ from solar_wavelength_calibration.fitter.wavelength_fitter import WavelengthParameters
9
14
 
10
15
  from dkist_processing_common._util.scratch import WorkflowFileSystem
11
16
  from dkist_processing_common.codecs.json import json_encoder
12
17
  from dkist_processing_common.codecs.quality import QualityValueEncoder
18
+ from dkist_processing_common.models.metric_code import MetricCode
19
+ from dkist_processing_common.models.quality import Plot2D
20
+ from dkist_processing_common.models.quality import VerticalMultiPanePlot2D
13
21
  from dkist_processing_common.models.tags import Tag
14
22
  from dkist_processing_common.tasks import WorkflowTaskBase
15
23
  from dkist_processing_common.tasks.mixin.quality import QualityMixin
@@ -21,7 +29,7 @@ class Task(WorkflowTaskBase, QualityMixin):
21
29
 
22
30
 
23
31
  @pytest.fixture
24
- def quality_task(tmp_path, recipe_run_id):
32
+ def quality_task(tmp_path, recipe_run_id) -> Task:
25
33
  with Task(
26
34
  recipe_run_id=recipe_run_id,
27
35
  workflow_name="workflow_name",
@@ -39,9 +47,9 @@ def quality_task(tmp_path, recipe_run_id):
39
47
  @pytest.fixture
40
48
  def plot_data():
41
49
  datetimes_a = ["2021-01-01T01:01:01", "2021-01-01T02:01:01"]
42
- values_a = [3, 4]
50
+ values_a = [0.1, 0.2]
43
51
  datetimes_b = ["2020-01-01T01:01:01", "2020-01-01T02:01:01"]
44
- values_b = [1, 2]
52
+ values_b = [0.15, 0.25]
45
53
  return datetimes_a, values_a, datetimes_b, values_b
46
54
 
47
55
 
@@ -106,6 +114,7 @@ def test_create_2d_plot_with_datetime_metric(quality_task):
106
114
  "facet",
107
115
  "statement",
108
116
  "plot_data",
117
+ "multi_plot_data",
109
118
  "histogram_data",
110
119
  "table_data",
111
120
  "modmat_data",
@@ -198,8 +207,9 @@ def test_store_ao_status_and_fried_parameter(quality_task, ao_values):
198
207
  """
199
208
  task = quality_task
200
209
  datetimes = ["2020-01-01T01:01:01", "2020-01-01T02:01:01"]
201
- fried_values = [3.0, 4.0]
202
- combined_values = [[ao, r0] for ao, r0 in zip(ao_values, fried_values)]
210
+ fried_values = [0.1, 0.2]
211
+ oob_values = [25, 50]
212
+ combined_values = [[ao, r0, oob] for ao, r0, oob in zip(ao_values, fried_values, oob_values)]
203
213
  task.quality_store_ao_status_and_fried_parameter(datetimes=datetimes, values=combined_values)
204
214
  path = list(task.read(tags=Tag.quality("AO_STATUS")))
205
215
  assert len(path) == 1
@@ -220,10 +230,11 @@ def test_store_ao_status_and_fried_parameter(quality_task, ao_values):
220
230
  @pytest.mark.parametrize(
221
231
  "combined_values",
222
232
  [
223
- pytest.param([[True, 1], [None, 2]], id="AO_some_none"),
224
- pytest.param([[True, 1], [True, None]], id="Fried_some_none"),
225
- pytest.param([[None, 1], [None, 2]], id="AO_all_none"),
226
- pytest.param([[True, None], [True, None]], id="Fried_all_none"),
233
+ pytest.param([[True, 0.1, 25], [None, 0.2, 25]], id="AO_some_none"),
234
+ pytest.param([[True, 0.1, 25], [True, None, 25]], id="Fried_some_none"),
235
+ pytest.param([[None, 0.1, 25], [None, 0.2, 25]], id="AO_all_none"),
236
+ pytest.param([[True, None, 25], [True, None, 25]], id="Fried_all_none"),
237
+ pytest.param([[True, 0.1, None], [True, 0.2, None]], id="Out_of_bounds_all_none"),
227
238
  ],
228
239
  )
229
240
  def test_store_ao_status_and_fried_parameter_with_nones(quality_task, combined_values):
@@ -231,13 +242,14 @@ def test_store_ao_status_and_fried_parameter_with_nones(quality_task, combined_v
231
242
  datetimes = ["2020-01-01T01:01:01", "2020-01-01T02:01:01"]
232
243
  task.quality_store_ao_status_and_fried_parameter(datetimes=datetimes, values=combined_values)
233
244
  path = list(task.read(tags=Tag.quality("AO_STATUS")))
234
- ao_values = [ao for ao, r0 in combined_values]
235
- fried_values = [r0 for ao, r0 in combined_values]
245
+ ao_values = [ao for ao, r0, oob in combined_values]
246
+ fried_values = [r0 for ao, r0, oob in combined_values]
247
+ ao_out_of_bounds = [oob for ao, r0, oob in combined_values]
236
248
  if not all(ao is None for ao in ao_values):
237
249
  assert len(path) == 1
238
250
  with path[0].open() as f:
239
251
  data = json.load(f)
240
- assert len(data) == sum(1 for ao, r0 in combined_values if ao is not None)
252
+ assert len(data) == sum(1 for ao, r0, oob in combined_values if ao is not None)
241
253
  else:
242
254
  assert len(path) == 0
243
255
  path = list(task.read(tags=Tag.quality("FRIED_PARAMETER")))
@@ -246,7 +258,7 @@ def test_store_ao_status_and_fried_parameter_with_nones(quality_task, combined_v
246
258
  with path[0].open() as f:
247
259
  data = json.load(f)
248
260
  assert len(data["y_values"]) == sum(
249
- 1 for ao, r0 in combined_values if ao is True and r0 is not None
261
+ 1 for ao, r0, oob in combined_values if ao is True and r0 is not None
250
262
  )
251
263
  else:
252
264
  assert len(path) == 0
@@ -263,7 +275,8 @@ def test_build_ao_status(quality_task, plot_data):
263
275
  datetimes = datetimes_a + datetimes_b
264
276
  fried_values = values_a + values_b
265
277
  ao_values = [False, True, True, True]
266
- combined_values = [[ao, r0] for ao, r0 in zip(ao_values, fried_values)]
278
+ oob_values = [25, 50, None, 50]
279
+ combined_values = [[ao, r0, oob] for ao, r0, oob in zip(ao_values, fried_values, oob_values)]
267
280
  task.quality_store_ao_status_and_fried_parameter(datetimes=datetimes, values=combined_values)
268
281
  metric = task.quality_build_ao_status()
269
282
  assert metric["name"] == "Adaptive Optics Status"
@@ -284,9 +297,15 @@ def test_build_fried_parameter(quality_task, plot_data):
284
297
  task = quality_task
285
298
  datetimes_a, fried_values_a, datetimes_b, fried_values_b = plot_data
286
299
  ao_values_a = [True, True]
287
- combined_values_a = [[ao, r0] for ao, r0 in zip(ao_values_a, fried_values_a)]
300
+ oob_values_a = [25, 50]
301
+ combined_values_a = [
302
+ [ao, r0, oob] for ao, r0, oob in zip(ao_values_a, fried_values_a, oob_values_a)
303
+ ]
288
304
  ao_values_b = [True, True]
289
- combined_values_b = [[ao, r0] for ao, r0 in zip(ao_values_b, fried_values_b)]
305
+ oob_values_b = [25, 50]
306
+ combined_values_b = [
307
+ [ao, r0, oob] for ao, r0, oob in zip(ao_values_b, fried_values_b, oob_values_b)
308
+ ]
290
309
  task.quality_store_ao_status_and_fried_parameter(
291
310
  datetimes=datetimes_a, values=combined_values_a
292
311
  )
@@ -307,14 +326,14 @@ def test_build_fried_parameter(quality_task, plot_data):
307
326
  "2021-01-01T02:01:01",
308
327
  ]
309
328
  ]
310
- assert metric["plot_data"]["series_data"][""][1] == [1, 2, 3, 4]
329
+ assert metric["plot_data"]["series_data"][""][1] == [0.15, 0.25, 0.1, 0.2]
311
330
  assert metric["name"] == "Fried Parameter"
312
331
  assert metric["metric_code"] == "FRIED_PARAMETER"
313
332
  assert metric["facet"] is None
314
333
  assert metric["warnings"] is None
315
334
  assert (
316
335
  metric["statement"]
317
- == "Average valid Fried Parameter measurements for L1 dataset: 2.5 ± 1.12 m"
336
+ == "Average valid Fried Parameter measurements for L1 dataset: 0.18 ± 0.06 m"
318
337
  )
319
338
 
320
339
 
@@ -340,12 +359,12 @@ def test_build_light_level(quality_task, plot_data):
340
359
  "2021-01-01T02:01:01",
341
360
  ]
342
361
  ]
343
- assert metric["plot_data"]["series_data"][""][1] == [1, 2, 3, 4]
362
+ assert metric["plot_data"]["series_data"][""][1] == [0.15, 0.25, 0.1, 0.2]
344
363
  assert metric["name"] == "Light Level"
345
364
  assert metric["metric_code"] == "LIGHT_LEVEL"
346
365
  assert metric["facet"] is None
347
366
  assert metric["warnings"] is None
348
- assert metric["statement"] == f"Average Light Level for L1 dataset: 2.5 ± 1.12 adu"
367
+ assert metric["statement"] == f"Average Light Level for L1 dataset: 0.18 ± 0.06 adu"
349
368
 
350
369
 
351
370
  def test_build_frame_average(quality_task, plot_data):
@@ -461,7 +480,7 @@ def test_build_noise(quality_task, plot_data):
461
480
  "2021-01-01T02:01:01",
462
481
  ]
463
482
  ]
464
- assert metric["plot_data"]["series_data"]["I"][1] == [1, 2, 3, 4]
483
+ assert metric["plot_data"]["series_data"]["I"][1] == [0.15, 0.25, 0.1, 0.2]
465
484
  assert metric["name"] == "Noise Estimation"
466
485
  assert metric["metric_code"] == "NOISE"
467
486
  assert metric["facet"] is None
@@ -493,7 +512,7 @@ def test_build_sensitivity(quality_task, plot_data):
493
512
  "2021-01-01T02:01:01",
494
513
  ]
495
514
  ]
496
- assert metric["plot_data"]["series_data"]["I"][1] == [1, 2, 3, 4]
515
+ assert metric["plot_data"]["series_data"]["I"][1] == [0.15, 0.25, 0.1, 0.2]
497
516
  assert metric["name"] == f"Sensitivity"
498
517
  assert metric["metric_code"] == "SENSITIVITY"
499
518
  assert metric["facet"] is None
@@ -692,7 +711,7 @@ def test_build_report(quality_task, plot_data):
692
711
  task.quality_store_task_type_counts(task_type="dark", total_frames=100, frames_not_used=7)
693
712
  task.quality_store_task_type_counts(task_type="gain", total_frames=100, frames_not_used=0)
694
713
  task.quality_store_ao_status_and_fried_parameter(
695
- datetimes=datetimes, values=[[True, values[0]], [True, values[1]]]
714
+ datetimes=datetimes, values=[[True, values[0], values[0]], [True, values[1], values[1]]]
696
715
  )
697
716
  task.quality_store_light_level(datetimes=datetimes, values=values)
698
717
  task.quality_store_frame_average(
@@ -1175,6 +1194,189 @@ def test_avg_noise_nan_values(quality_task, array_shape):
1175
1194
  assert not np.isnan(result)
1176
1195
 
1177
1196
 
1197
+ @pytest.fixture(scope="session")
1198
+ def wavecal_input_wavelength() -> u.Quantity:
1199
+ return np.arange(100) * u.nm
1200
+
1201
+
1202
+ @pytest.fixture(scope="session")
1203
+ def wavecal_input_spectrum(wavecal_input_wavelength) -> np.ndarray:
1204
+ spec = (wavecal_input_wavelength.value - wavecal_input_wavelength.size // 2) ** 2 + 10.0
1205
+ spec[spec.size // 2] = np.nan
1206
+ return spec
1207
+
1208
+
1209
+ @pytest.fixture(scope="session")
1210
+ def wavecal_weights(wavecal_input_wavelength) -> np.ndarray:
1211
+ weights = np.arange(wavecal_input_wavelength.size, dtype=float)
1212
+ weights[0] = np.inf
1213
+ return weights
1214
+
1215
+
1216
+ @pytest.fixture(scope="session")
1217
+ def wavecal_fit_result(wavecal_input_wavelength, wavecal_input_spectrum) -> FitResult:
1218
+ wavelength_params = WavelengthParameters(
1219
+ crpix=1, crval=10.0, dispersion=1, grating_constant=1, order=1, incident_light_angle=0
1220
+ )
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
+ )
1231
+
1232
+
1233
+ @pytest.mark.parametrize(
1234
+ "use_weights",
1235
+ [pytest.param(True, id="custom_weights"), pytest.param(False, id="default_weights")],
1236
+ )
1237
+ def test_wavecal_store_results(
1238
+ quality_task,
1239
+ wavecal_input_wavelength,
1240
+ wavecal_input_spectrum,
1241
+ wavecal_fit_result,
1242
+ wavecal_weights,
1243
+ use_weights,
1244
+ ):
1245
+ """
1246
+ Given: A task with the QualityMixin and the results of a wavecal fit
1247
+ When: Storing the wavecal metric
1248
+ Then: The correct metric json files are written and their contents contain the correct types of data
1249
+ """
1250
+ quality_task.quality_store_wavecal_results(
1251
+ input_wavelength=wavecal_input_wavelength,
1252
+ input_spectrum=wavecal_input_spectrum,
1253
+ fit_result=wavecal_fit_result,
1254
+ weights=wavecal_weights if use_weights else None,
1255
+ )
1256
+
1257
+ wavecal_quality_files = list(quality_task.read(tags=[Tag.quality(MetricCode.wavecal_fit)]))
1258
+ assert len(wavecal_quality_files) == 1
1259
+ with open(wavecal_quality_files[0], "r") as f:
1260
+ results_dict = json.load(f)
1261
+ assert sorted(results_dict.keys()) == sorted(
1262
+ [
1263
+ "input_wavelength_nm",
1264
+ "input_spectrum",
1265
+ "best_fit_wavelength_nm",
1266
+ "best_fit_atlas",
1267
+ "normalized_residuals",
1268
+ "weights",
1269
+ ]
1270
+ )
1271
+ for k, v in results_dict.items():
1272
+ if k != "weights" or use_weights:
1273
+ assert isinstance(v, list)
1274
+ assert len(v) == len(results_dict["input_wavelength_nm"])
1275
+ if not use_weights:
1276
+ assert results_dict["weights"] is None
1277
+
1278
+
1279
+ @pytest.fixture(
1280
+ scope="session",
1281
+ params=[pytest.param([0, 1.0, 0.8, 0.0], id="weights"), pytest.param(None, id="no_weights")],
1282
+ )
1283
+ def wavecal_data_json(request) -> dict:
1284
+ weights = request.param
1285
+ return {
1286
+ "input_wavelength_nm": [1001.0, 1002.0, 1003.0, 1004.0],
1287
+ "input_spectrum": [1.0, 1.0, 0.5, 1.0],
1288
+ "best_fit_wavelength_nm": [1001.5, 1002.6, 1003.7, 1004.8],
1289
+ "best_fit_atlas": [1.0, 1.0, 0.4, 1.0],
1290
+ "normalized_residuals": [0.0, 0.0, 0.1, 0.0],
1291
+ "weights": weights,
1292
+ }
1293
+
1294
+
1295
+ def test_build_wavecal_results(quality_task, wavecal_data_json):
1296
+ """
1297
+ Given: A task with the QualityMixin
1298
+ When: Building the wavecal results quality metric
1299
+ Then: The correct metric model is returned
1300
+ """
1301
+ weights_included = wavecal_data_json["weights"] is not None
1302
+ quality_task.write(
1303
+ data=wavecal_data_json,
1304
+ tags=[Tag.quality(MetricCode.wavecal_fit)],
1305
+ encoder=json_encoder,
1306
+ allow_nan=False,
1307
+ cls=QualityValueEncoder,
1308
+ )
1309
+ metric = quality_task.quality_build_wavecal_results()
1310
+
1311
+ assert metric["name"] == "Wavelength Calibration Results"
1312
+ assert metric["description"] == (
1313
+ "These plots show the wavelength solution computed based on fits to a Solar FTS atlas. "
1314
+ "The top plot shows the input and best-fit spectra along with the best-fit atlas, which is "
1315
+ "a combination of Solar and Telluric spectra. The bottom plot shows the fit residuals."
1316
+ )
1317
+ assert metric["metric_code"] == MetricCode.wavecal_fit.value
1318
+ assert metric["facet"] is None
1319
+ assert metric["statement"] is None
1320
+ assert metric["plot_data"] is None
1321
+ assert metric["histogram_data"] is None
1322
+ assert metric["table_data"] is None
1323
+ assert metric["modmat_data"] is None
1324
+ assert metric["efficiency_data"] is None
1325
+ assert metric["raincloud_data"] is None
1326
+ assert metric["warnings"] is None
1327
+
1328
+ multi_plot_data = metric["multi_plot_data"]
1329
+ assert multi_plot_data["match_x_axes"] is True
1330
+ assert multi_plot_data["no_gap"] is True
1331
+ assert (
1332
+ multi_plot_data["top_to_bottom_height_ratios"] == [1.5, 1, 1]
1333
+ if weights_included
1334
+ else [1.5, 1]
1335
+ )
1336
+ plot_list = multi_plot_data["top_to_bottom_plot_list"]
1337
+ assert isinstance(plot_list, list)
1338
+ assert len(plot_list) == 3 if weights_included else 3
1339
+
1340
+ fit_plot = plot_list[0]
1341
+ assert fit_plot["sort_series"] is False
1342
+ assert fit_plot["xlabel"] == "Wavelength [nm]"
1343
+ assert fit_plot["ylabel"] == "Signal"
1344
+ assert fit_plot["series_data"] == {
1345
+ "Input Spectrum": [[1001.0, 1002.0, 1003.0, 1004.0], [1.0, 1.0, 0.5, 1.0]],
1346
+ "Best Fit Observations": [[1001.5, 1002.6, 1003.7, 1004.8], [1.0, 1.0, 0.5, 1.0]],
1347
+ "Best Fit Atlas": [[1001.5, 1002.6, 1003.7, 1004.8], [1.0, 1.0, 0.4, 1.0]],
1348
+ }
1349
+ assert fit_plot["plot_kwargs"] == {
1350
+ "Input Spectrum": {"ls": "-", "alpha": 0.4, "ms": 0, "color": "#1E317A", "zorder": 2.1},
1351
+ "Best Fit Observations": {
1352
+ "ls": "-",
1353
+ "lw": 4,
1354
+ "alpha": 0.8,
1355
+ "ms": 0,
1356
+ "color": "#FAA61C",
1357
+ "zorder": 2.0,
1358
+ },
1359
+ "Best Fit Atlas": {"color": "k", "ls": "-", "ms": 0, "zorder": 2.2},
1360
+ }
1361
+
1362
+ residuals_plot = plot_list[1]
1363
+ assert residuals_plot["xlabel"] == "Wavelength [nm]"
1364
+ assert residuals_plot["ylabel"] == r"$\frac{\mathrm{Obs - Atlas}}{\mathrm{Obs}}$"
1365
+ assert residuals_plot["series_data"] == {
1366
+ "Residuals": [[1001.5, 1002.6, 1003.7, 1004.8], [0.0, 0.0, 0.1, 0.0]]
1367
+ }
1368
+ assert residuals_plot["plot_kwargs"] == {"Residuals": {"ls": "-", "color": "k", "ms": 0}}
1369
+
1370
+ if weights_included:
1371
+ weights_plot = plot_list[2]
1372
+ assert weights_plot["xlabel"] == "Wavelength [nm]"
1373
+ assert weights_plot["ylabel"] == "Fit Weights"
1374
+ assert weights_plot["series_data"] == {
1375
+ "Weights": [[1001.5, 1002.6, 1003.7, 1004.8], [0.0, 1.0, 0.8, 0.0]]
1376
+ }
1377
+ assert weights_plot["plot_kwargs"] == {"Weights": {"ls": "-", "color": "k", "ms": 0}}
1378
+
1379
+
1178
1380
  @pytest.mark.parametrize(
1179
1381
  "bin_strs, sampled_bins, expected_bin_str, expected_sample_str",
1180
1382
  [
@@ -1245,3 +1447,33 @@ def test_format_facet(label: str | Any, expected_result: str):
1245
1447
  Then: the label is properly formatted
1246
1448
  """
1247
1449
  assert QualityMixin._format_facet(label) == expected_result
1450
+
1451
+
1452
+ def test_validate_vertical_multi_pane_plot_model():
1453
+ """
1454
+ Given: A `VerticalMultiPanePlot2D` model and some `Plot2D` models
1455
+ When: Instantiating the `VerticalMultiPanePlot2D` with various parameters
1456
+ Then: The `top_to_bottom_plot_ratios` property is correctly populated
1457
+ """
1458
+ plot2d = Plot2D(xlabel="X", ylabel="Y", series_data={"Foo": [[1.0], [2.0]]})
1459
+
1460
+ # Test given ratios valid case
1461
+ _ = VerticalMultiPanePlot2D(
1462
+ top_to_bottom_plot_list=[plot2d, plot2d], top_to_bottom_height_ratios=[1.0, 2.0]
1463
+ )
1464
+
1465
+ # Test None ratios
1466
+ vertical_plots = VerticalMultiPanePlot2D(
1467
+ top_to_bottom_plot_list=[plot2d, plot2d], top_to_bottom_height_ratios=None
1468
+ )
1469
+ assert vertical_plots.top_to_bottom_height_ratios == [1.0, 1.0]
1470
+
1471
+ # Test invalid case
1472
+ with pytest.raises(
1473
+ ValidationError,
1474
+ match="The number of items in `top_to_bottom_height_ratios` list \(3\) is not "
1475
+ "the same as the number of plots \(2\)",
1476
+ ):
1477
+ _ = VerticalMultiPanePlot2D(
1478
+ top_to_bottom_plot_list=[plot2d, plot2d], top_to_bottom_height_ratios=[1.0, 2.0, 3.0]
1479
+ )
@@ -1,14 +1,15 @@
1
1
  """
2
2
  Tests for the workflow file system wrapper
3
3
  """
4
+
4
5
  from pathlib import Path
5
6
  from typing import Callable
6
7
  from uuid import uuid4
7
8
 
8
9
  import pytest
9
10
 
10
- from dkist_processing_common._util.scratch import _flatten_list
11
11
  from dkist_processing_common._util.scratch import WorkflowFileSystem
12
+ from dkist_processing_common._util.scratch import _flatten_list
12
13
 
13
14
 
14
15
  @pytest.fixture(