albert 1.10.0rc2__py3-none-any.whl → 1.11.0__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 (36) hide show
  1. albert/__init__.py +1 -1
  2. albert/client.py +5 -0
  3. albert/collections/custom_templates.py +3 -0
  4. albert/collections/data_templates.py +118 -264
  5. albert/collections/entity_types.py +19 -3
  6. albert/collections/inventory.py +1 -1
  7. albert/collections/notebooks.py +154 -26
  8. albert/collections/parameters.py +1 -0
  9. albert/collections/property_data.py +384 -280
  10. albert/collections/reports.py +4 -0
  11. albert/collections/synthesis.py +292 -0
  12. albert/collections/tasks.py +2 -1
  13. albert/collections/worksheets.py +3 -0
  14. albert/core/shared/models/base.py +3 -1
  15. albert/core/shared/models/patch.py +1 -1
  16. albert/resources/batch_data.py +4 -2
  17. albert/resources/cas.py +3 -1
  18. albert/resources/custom_fields.py +3 -1
  19. albert/resources/data_templates.py +60 -12
  20. albert/resources/inventory.py +6 -4
  21. albert/resources/lists.py +3 -1
  22. albert/resources/notebooks.py +12 -7
  23. albert/resources/parameter_groups.py +3 -1
  24. albert/resources/property_data.py +64 -5
  25. albert/resources/sheets.py +16 -14
  26. albert/resources/synthesis.py +61 -0
  27. albert/resources/tags.py +3 -1
  28. albert/resources/tasks.py +4 -7
  29. albert/resources/workflows.py +4 -2
  30. albert/utils/data_template.py +392 -37
  31. albert/utils/property_data.py +638 -0
  32. albert/utils/tasks.py +3 -3
  33. {albert-1.10.0rc2.dist-info → albert-1.11.0.dist-info}/METADATA +1 -1
  34. {albert-1.10.0rc2.dist-info → albert-1.11.0.dist-info}/RECORD +36 -33
  35. {albert-1.10.0rc2.dist-info → albert-1.11.0.dist-info}/WHEEL +0 -0
  36. {albert-1.10.0rc2.dist-info → albert-1.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,4 +1,3 @@
1
- import re
2
1
  from collections.abc import Iterator
3
2
  from contextlib import suppress
4
3
  from enum import Enum
@@ -23,26 +22,24 @@ from albert.core.shared.identifiers import (
23
22
  TaskId,
24
23
  UserId,
25
24
  )
26
- from albert.core.shared.models.base import EntityLink
27
25
  from albert.core.shared.models.patch import PatchOperation
28
26
  from albert.exceptions import NotFoundError
29
27
  from albert.resources.property_data import (
30
28
  BulkPropertyData,
31
29
  CheckPropertyData,
30
+ CurvePropertyValue,
32
31
  DataEntity,
32
+ ImagePropertyValue,
33
33
  InventoryDataColumn,
34
34
  InventoryPropertyData,
35
35
  InventoryPropertyDataCreate,
36
36
  PropertyDataPatchDatum,
37
37
  PropertyDataSearchItem,
38
- PropertyValue,
39
38
  ReturnScope,
40
- TaskDataColumn,
41
39
  TaskPropertyCreate,
42
40
  TaskPropertyData,
43
- Trial,
44
41
  )
45
- from albert.resources.tasks import PropertyTask
42
+ from albert.utils import property_data as property_data_utils
46
43
 
47
44
 
48
45
  class PropertyDataCollection(BaseCollection):
@@ -62,12 +59,6 @@ class PropertyDataCollection(BaseCollection):
62
59
  super().__init__(session=session)
63
60
  self.base_path = f"/api/{PropertyDataCollection._api_version}/propertydata"
64
61
 
65
- @validate_call
66
- def _get_task_from_id(self, *, id: TaskId) -> PropertyTask:
67
- from albert.collections.tasks import TaskCollection
68
-
69
- return TaskCollection(session=self.session).get_by_id(id=id)
70
-
71
62
  @validate_call
72
63
  def get_properties_on_inventory(self, *, inventory_id: InventoryId) -> InventoryPropertyData:
73
64
  """Returns all the properties of an inventory item.
@@ -233,7 +224,7 @@ class PropertyDataCollection(BaseCollection):
233
224
  list[CheckPropertyData]
234
225
  A list of CheckPropertyData entities representing the data status of each block + inventory item of the task.
235
226
  """
236
- task_info = self._get_task_from_id(id=task_id)
227
+ task_info = property_data_utils.get_task_from_id(session=self.session, id=task_id)
237
228
 
238
229
  params = {
239
230
  "entity": "block",
@@ -263,7 +254,7 @@ class PropertyDataCollection(BaseCollection):
263
254
  Returns
264
255
  -------
265
256
  CheckPropertyData
266
- _description_
257
+ CheckPropertyData representing the data status of each block + inventory item of the task.
267
258
  """
268
259
  params = {
269
260
  "entity": "block",
@@ -310,37 +301,6 @@ class PropertyDataCollection(BaseCollection):
310
301
  )
311
302
  return all_info
312
303
 
313
- def _resolve_return_scope(
314
- self,
315
- *,
316
- task_id: TaskId,
317
- return_scope: ReturnScope,
318
- inventory_id: InventoryId | None,
319
- block_id: BlockId | None,
320
- lot_id: LotId | None,
321
- prefetched_block: TaskPropertyData | None = None,
322
- ) -> list[TaskPropertyData]:
323
- if return_scope == "task":
324
- return self.get_all_task_properties(task_id=task_id)
325
-
326
- if return_scope == "block":
327
- if prefetched_block is not None:
328
- return [prefetched_block]
329
- if inventory_id is None or block_id is None:
330
- raise ValueError(
331
- "inventory_id and block_id are required when return_scope='combo'."
332
- )
333
- return [
334
- self.get_task_block_properties(
335
- inventory_id=inventory_id,
336
- task_id=task_id,
337
- block_id=block_id,
338
- lot_id=lot_id,
339
- )
340
- ]
341
-
342
- return []
343
-
344
304
  @validate_call
345
305
  def update_property_on_task(
346
306
  self,
@@ -359,7 +319,8 @@ class PropertyDataCollection(BaseCollection):
359
319
  task_id : TaskId
360
320
  The ID of the task.
361
321
  patch_payload : list[PropertyDataPatchDatum]
362
- The specific patch to make to update the property.
322
+ The specific patch to make to update the property. ImagePropertyValue and
323
+ CurvePropertyValue updates require update_or_create_task_properties.
363
324
  inventory_id : InventoryId | None, optional
364
325
  Required when return_scope="block".
365
326
  block_id : BlockId | None, optional
@@ -376,19 +337,302 @@ class PropertyDataCollection(BaseCollection):
376
337
  A list of TaskPropertyData entities representing the properties within the task.
377
338
  """
378
339
  if len(patch_payload) > 0:
340
+ resolved_payload = property_data_utils.resolve_patch_payload(
341
+ session=self.session,
342
+ task_id=task_id,
343
+ patch_payload=patch_payload,
344
+ )
379
345
  self.session.patch(
380
346
  url=f"{self.base_path}/{task_id}",
381
- json=[
382
- x.model_dump(exclude_none=True, by_alias=True, mode="json")
383
- for x in patch_payload
384
- ],
347
+ json=resolved_payload,
385
348
  )
386
- return self._resolve_return_scope(
349
+ return property_data_utils.resolve_return_scope(
387
350
  task_id=task_id,
388
351
  return_scope=return_scope,
389
352
  inventory_id=inventory_id,
390
353
  block_id=block_id,
391
354
  lot_id=lot_id,
355
+ prefetched_block=None,
356
+ get_all_task_properties=self.get_all_task_properties,
357
+ get_task_block_properties=self.get_task_block_properties,
358
+ )
359
+
360
+ @validate_call
361
+ def void_task_data(
362
+ self,
363
+ *,
364
+ task_id: TaskId,
365
+ inventory_id: InventoryId,
366
+ block_id: BlockId,
367
+ lot_id: LotId | None = None,
368
+ ) -> None:
369
+ """Void all property data for a task.
370
+
371
+ Parameters
372
+ ----------
373
+ task_id : TaskId
374
+ The ID of the task.
375
+ inventory_id : InventoryId
376
+ The ID of the inventory item.
377
+ block_id : BlockId
378
+ The ID of the block.
379
+ lot_id : LotId | None, optional
380
+ The ID of the lot, by default None.
381
+
382
+ Returns
383
+ -------
384
+ None
385
+ """
386
+ payload = {
387
+ "operation": "void",
388
+ "by": "task",
389
+ "id": task_id,
390
+ "inventoryId": inventory_id,
391
+ "blockId": block_id,
392
+ "lotId": lot_id,
393
+ }
394
+ payload = {k: v for k, v in payload.items() if v is not None}
395
+ self.session.patch(
396
+ url=f"{self.base_path}/{task_id}",
397
+ json=payload,
398
+ )
399
+
400
+ @validate_call
401
+ def unvoid_task_data(
402
+ self,
403
+ *,
404
+ task_id: TaskId,
405
+ inventory_id: InventoryId,
406
+ block_id: BlockId,
407
+ lot_id: LotId | None = None,
408
+ ) -> None:
409
+ """Unvoid all property data for a task.
410
+
411
+ Parameters
412
+ ----------
413
+ task_id : TaskId
414
+ The ID of the task.
415
+ inventory_id : InventoryId
416
+ The ID of the inventory item.
417
+ block_id : BlockId
418
+ The ID of the block.
419
+ lot_id : LotId | None, optional
420
+ The ID of the lot, by default None.
421
+
422
+ Returns
423
+ -------
424
+ None
425
+ """
426
+ payload = {
427
+ "operation": "unvoid",
428
+ "by": "task",
429
+ "id": task_id,
430
+ "inventoryId": inventory_id,
431
+ "blockId": block_id,
432
+ "lotId": lot_id,
433
+ }
434
+ payload = {k: v for k, v in payload.items() if v is not None}
435
+ self.session.patch(
436
+ url=f"{self.base_path}/{task_id}",
437
+ json=payload,
438
+ )
439
+
440
+ @validate_call
441
+ def void_interval_data(
442
+ self,
443
+ *,
444
+ task_id: TaskId,
445
+ interval_id: str,
446
+ inventory_id: InventoryId,
447
+ block_id: BlockId,
448
+ lot_id: LotId | None = None,
449
+ data_template_id: DataTemplateId | None = None,
450
+ ) -> None:
451
+ """Void all property data for a specific interval combination.
452
+
453
+ Parameters
454
+ ----------
455
+ task_id : TaskId
456
+ The ID of the task.
457
+ interval_id : str
458
+ The interval combination identifier (``CheckPropertyData.interval_id``).
459
+ Use ``check_for_task_data`` to list interval combinations for a task.
460
+ inventory_id : InventoryId
461
+ The ID of the inventory item.
462
+ block_id : BlockId
463
+ The ID of the block.
464
+ lot_id : LotId | None, optional
465
+ The ID of the lot, by default None.
466
+ data_template_id : DataTemplateId | None, optional
467
+ When provided, limits the voiding to a specific data template.
468
+
469
+ Returns
470
+ -------
471
+ None
472
+ """
473
+ payload = {
474
+ "operation": "void",
475
+ "by": "intervalCombination",
476
+ "id": interval_id,
477
+ "parentId": task_id,
478
+ "inventoryId": inventory_id,
479
+ "blockId": block_id,
480
+ "lotId": lot_id,
481
+ "dataTemplateId": data_template_id,
482
+ }
483
+ payload = {k: v for k, v in payload.items() if v is not None}
484
+ self.session.patch(
485
+ url=f"{self.base_path}/{task_id}",
486
+ json=payload,
487
+ )
488
+
489
+ @validate_call
490
+ def unvoid_interval_data(
491
+ self,
492
+ *,
493
+ task_id: TaskId,
494
+ interval_id: str,
495
+ inventory_id: InventoryId,
496
+ block_id: BlockId,
497
+ lot_id: LotId | None = None,
498
+ data_template_id: DataTemplateId | None = None,
499
+ ) -> None:
500
+ """Unvoid all property data for a specific interval combination.
501
+
502
+ Parameters
503
+ ----------
504
+ task_id : TaskId
505
+ The ID of the task.
506
+ interval_id : str
507
+ The interval combination identifier (``CheckPropertyData.interval_id``).
508
+ Use ``check_for_task_data`` to list interval combinations for a task.
509
+ inventory_id : InventoryId
510
+ The ID of the inventory item.
511
+ block_id : BlockId
512
+ The ID of the block.
513
+ lot_id : LotId | None, optional
514
+ The ID of the lot, by default None.
515
+ data_template_id : DataTemplateId | None, optional
516
+ When provided, limits the unvoiding to a specific data template.
517
+
518
+ Returns
519
+ -------
520
+ None
521
+ """
522
+ payload = {
523
+ "operation": "unvoid",
524
+ "by": "intervalCombination",
525
+ "id": interval_id,
526
+ "parentId": task_id,
527
+ "inventoryId": inventory_id,
528
+ "blockId": block_id,
529
+ "lotId": lot_id,
530
+ "dataTemplateId": data_template_id,
531
+ }
532
+ payload = {k: v for k, v in payload.items() if v is not None}
533
+ self.session.patch(
534
+ url=f"{self.base_path}/{task_id}",
535
+ json=payload,
536
+ )
537
+
538
+ @validate_call
539
+ def void_trial_data(
540
+ self,
541
+ *,
542
+ task_id: TaskId,
543
+ interval_id: str,
544
+ trial_number: int,
545
+ inventory_id: InventoryId,
546
+ block_id: BlockId,
547
+ lot_id: LotId | None = None,
548
+ ) -> None:
549
+ """Void property data for a specific trial in an interval combination.
550
+
551
+ Parameters
552
+ ----------
553
+ task_id : TaskId
554
+ The ID of the task.
555
+ interval_id : str
556
+ The interval combination identifier (``CheckPropertyData.interval_id``).
557
+ Use ``check_for_task_data`` to list interval combinations for a task.
558
+ trial_number : int
559
+ The trial number to void.
560
+ inventory_id : InventoryId
561
+ The ID of the inventory item.
562
+ block_id : BlockId
563
+ The ID of the block.
564
+ lot_id : LotId | None, optional
565
+ The ID of the lot, by default None.
566
+
567
+ Returns
568
+ -------
569
+ None
570
+ """
571
+ payload = [
572
+ {
573
+ "operation": "void",
574
+ "by": "trial",
575
+ "trial": trial_number,
576
+ "id": interval_id,
577
+ "inventoryId": inventory_id,
578
+ "blockId": block_id,
579
+ "lotId": lot_id,
580
+ }
581
+ ]
582
+ payload = [{k: v for k, v in item.items() if v is not None} for item in payload]
583
+ self.session.patch(
584
+ url=f"{self.base_path}/{task_id}",
585
+ json=payload,
586
+ )
587
+
588
+ @validate_call
589
+ def unvoid_trial_data(
590
+ self,
591
+ *,
592
+ task_id: TaskId,
593
+ interval_id: str,
594
+ trial_number: int,
595
+ inventory_id: InventoryId,
596
+ block_id: BlockId,
597
+ lot_id: LotId | None = None,
598
+ ) -> None:
599
+ """Unvoid property data for a specific trial in an interval combination.
600
+
601
+ Parameters
602
+ ----------
603
+ task_id : TaskId
604
+ The ID of the task.
605
+ interval_id : str
606
+ The interval combination identifier (``CheckPropertyData.interval_id``).
607
+ Use ``check_for_task_data`` to list interval combinations for a task.
608
+ trial_number : int
609
+ The trial number to unvoid.
610
+ inventory_id : InventoryId
611
+ The ID of the inventory item.
612
+ block_id : BlockId
613
+ The ID of the block.
614
+ lot_id : LotId | None, optional
615
+ The ID of the lot, by default None.
616
+
617
+ Returns
618
+ -------
619
+ None
620
+ """
621
+ payload = [
622
+ {
623
+ "operation": "unvoid",
624
+ "by": "trial",
625
+ "trial": trial_number,
626
+ "id": interval_id,
627
+ "inventoryId": inventory_id,
628
+ "blockId": block_id,
629
+ "lotId": lot_id,
630
+ }
631
+ ]
632
+ payload = [{k: v for k, v in item.items() if v is not None} for item in payload]
633
+ self.session.patch(
634
+ url=f"{self.base_path}/{task_id}",
635
+ json=payload,
392
636
  )
393
637
 
394
638
  @validate_call
@@ -439,20 +683,33 @@ class PropertyDataCollection(BaseCollection):
439
683
  "history": "true",
440
684
  }
441
685
  params = {k: v for k, v in params.items() if v is not None}
686
+ payload = (
687
+ property_data_utils.resolve_task_property_payload(
688
+ session=self.session,
689
+ task_id=task_id,
690
+ block_id=block_id,
691
+ properties=properties,
692
+ )
693
+ if any(
694
+ isinstance(prop.value, ImagePropertyValue | CurvePropertyValue)
695
+ for prop in properties
696
+ )
697
+ else [x.model_dump(exclude_none=True, by_alias=True, mode="json") for x in properties]
698
+ )
442
699
  response = self.session.post(
443
700
  url=f"{self.base_path}/{task_id}",
444
- json=[x.model_dump(exclude_none=True, by_alias=True, mode="json") for x in properties],
701
+ json=payload,
445
702
  params=params,
446
703
  )
447
-
448
704
  registered_properties = [
449
705
  TaskPropertyCreate(**x) for x in response.json() if "DataTemplate" in x
450
706
  ]
451
707
  existing_data_rows = self.get_task_block_properties(
452
708
  inventory_id=inventory_id, task_id=task_id, block_id=block_id, lot_id=lot_id
453
709
  )
454
- patches = self._form_calculated_task_property_patches(
455
- existing_data_rows=existing_data_rows, properties=registered_properties
710
+ patches = property_data_utils.form_calculated_task_property_patches(
711
+ existing_data_rows=existing_data_rows,
712
+ properties=registered_properties,
456
713
  )
457
714
  if len(patches) > 0:
458
715
  return self.update_property_on_task(
@@ -464,13 +721,15 @@ class PropertyDataCollection(BaseCollection):
464
721
  lot_id=lot_id,
465
722
  )
466
723
 
467
- return self._resolve_return_scope(
724
+ return property_data_utils.resolve_return_scope(
468
725
  task_id=task_id,
469
726
  return_scope=return_scope,
470
727
  inventory_id=inventory_id,
471
728
  block_id=block_id,
472
729
  lot_id=lot_id,
473
730
  prefetched_block=existing_data_rows,
731
+ get_all_task_properties=self.get_all_task_properties,
732
+ get_task_block_properties=self.get_task_block_properties,
474
733
  )
475
734
 
476
735
  @validate_call
@@ -514,15 +773,21 @@ class PropertyDataCollection(BaseCollection):
514
773
  The updated or newly created task properties.
515
774
 
516
775
  """
776
+
517
777
  existing_data_rows = self.get_task_block_properties(
518
778
  inventory_id=inventory_id, task_id=task_id, block_id=block_id, lot_id=lot_id
519
779
  )
520
- update_patches, new_values = self._form_existing_row_value_patches(
521
- existing_data_rows=existing_data_rows, properties=properties
780
+ update_patches, new_values = property_data_utils.form_existing_row_value_patches(
781
+ session=self.session,
782
+ task_id=task_id,
783
+ block_id=block_id,
784
+ existing_data_rows=existing_data_rows,
785
+ properties=properties,
522
786
  )
523
787
 
524
- calculated_patches = self._form_calculated_task_property_patches(
525
- existing_data_rows=existing_data_rows, properties=properties
788
+ calculated_patches = property_data_utils.form_calculated_task_property_patches(
789
+ existing_data_rows=existing_data_rows,
790
+ properties=properties,
526
791
  )
527
792
  all_patches = update_patches + calculated_patches
528
793
  if len(new_values) > 0:
@@ -535,6 +800,58 @@ class PropertyDataCollection(BaseCollection):
535
800
  block_id=block_id,
536
801
  lot_id=lot_id,
537
802
  )
803
+ if any(
804
+ isinstance(prop.value, ImagePropertyValue | CurvePropertyValue)
805
+ for prop in new_values
806
+ ):
807
+ params = {
808
+ "blockId": block_id,
809
+ "inventoryId": inventory_id,
810
+ }
811
+ params = {k: v for k, v in params.items() if v is not None}
812
+ payload = property_data_utils.resolve_task_property_payload(
813
+ session=self.session,
814
+ task_id=task_id,
815
+ block_id=block_id,
816
+ properties=new_values,
817
+ )
818
+ response = self.session.post(
819
+ url=f"{self.base_path}/{task_id}",
820
+ json=payload,
821
+ params=params,
822
+ )
823
+ registered_properties = [
824
+ TaskPropertyCreate(**x) for x in response.json() if "DataTemplate" in x
825
+ ]
826
+ existing_data_rows = self.get_task_block_properties(
827
+ inventory_id=inventory_id,
828
+ task_id=task_id,
829
+ block_id=block_id,
830
+ lot_id=lot_id,
831
+ )
832
+ patches = property_data_utils.form_calculated_task_property_patches(
833
+ existing_data_rows=existing_data_rows,
834
+ properties=registered_properties,
835
+ )
836
+ if len(patches) > 0:
837
+ return self.update_property_on_task(
838
+ task_id=task_id,
839
+ patch_payload=patches,
840
+ return_scope=return_scope,
841
+ inventory_id=inventory_id,
842
+ block_id=block_id,
843
+ lot_id=lot_id,
844
+ )
845
+ return property_data_utils.resolve_return_scope(
846
+ task_id=task_id,
847
+ return_scope=return_scope,
848
+ inventory_id=inventory_id,
849
+ block_id=block_id,
850
+ lot_id=lot_id,
851
+ prefetched_block=existing_data_rows,
852
+ get_all_task_properties=self.get_all_task_properties,
853
+ get_task_block_properties=self.get_task_block_properties,
854
+ )
538
855
  return self.add_properties_to_task(
539
856
  inventory_id=inventory_id,
540
857
  task_id=task_id,
@@ -612,51 +929,18 @@ class PropertyDataCollection(BaseCollection):
612
929
  {x.data_column_name: x.data_series for x in property_data.columns}
613
930
  )
614
931
 
615
- def _get_column_map(dataframe: pd.DataFrame, property_data: TaskPropertyData):
616
- data_col_info = property_data.data[0].trials[0].data_columns # PropertyValue
617
- column_map = {}
618
- for col in dataframe.columns:
619
- column = [x for x in data_col_info if x.name == col]
620
- if len(column) == 1:
621
- column_map[col] = column[0]
622
- else:
623
- raise ValueError(
624
- f"Column '{col}' not found in block data columns or multiple matches found."
625
- )
626
- return column_map
627
-
628
- def _df_to_task_prop_create_list(
629
- dataframe: pd.DataFrame,
630
- column_map: dict[str, PropertyValue],
631
- data_template_id: DataTemplateId,
632
- ) -> list[TaskPropertyCreate]:
633
- task_prop_create_list = []
634
- for i, row in dataframe.iterrows():
635
- for col_name, col_info in column_map.items():
636
- if col_name not in dataframe.columns:
637
- raise ValueError(f"Column '{col_name}' not found in DataFrame.")
638
-
639
- task_prop_create = TaskPropertyCreate(
640
- data_column=TaskDataColumn(
641
- data_column_id=col_info.id,
642
- column_sequence=col_info.sequence,
643
- ),
644
- value=str(row[col_name]),
645
- visible_trial_number=i + 1,
646
- interval_combination=interval,
647
- data_template=EntityLink(id=data_template_id),
648
- )
649
- task_prop_create_list.append(task_prop_create)
650
- return task_prop_create_list
651
-
652
932
  task_prop_data = self.get_task_block_properties(
653
933
  inventory_id=inventory_id, task_id=task_id, block_id=block_id, lot_id=lot_id
654
934
  )
655
- column_map = _get_column_map(property_df, task_prop_data)
656
- all_task_prop_create = _df_to_task_prop_create_list(
935
+ column_map = property_data_utils._get_column_map(
936
+ dataframe=property_df,
937
+ property_data=task_prop_data,
938
+ )
939
+ all_task_prop_create = property_data_utils._df_to_task_prop_create_list(
657
940
  dataframe=property_df,
658
941
  column_map=column_map,
659
942
  data_template_id=task_prop_data.data_template.id,
943
+ interval=interval,
660
944
  )
661
945
  with suppress(NotFoundError):
662
946
  # This is expected if the task is new and has no data yet.
@@ -676,6 +960,7 @@ class PropertyDataCollection(BaseCollection):
676
960
  return_scope=return_scope,
677
961
  )
678
962
 
963
+ @validate_call
679
964
  def bulk_delete_task_data(
680
965
  self,
681
966
  *,
@@ -714,187 +999,6 @@ class PropertyDataCollection(BaseCollection):
714
999
  params = {k: v for k, v in params.items() if v is not None}
715
1000
  self.session.delete(f"{self.base_path}/{task_id}", params=params)
716
1001
 
717
- ################### Methods to support Updated Row Value patches #################
718
-
719
- def _form_existing_row_value_patches(
720
- self, *, existing_data_rows: TaskPropertyData, properties: list[TaskPropertyCreate]
721
- ):
722
- patches = []
723
- new_properties = []
724
-
725
- for prop in properties:
726
- if prop.trial_number is None:
727
- new_properties.append(prop)
728
- continue
729
-
730
- prop_patches = self._process_property(prop, existing_data_rows)
731
- if prop_patches:
732
- patches.extend(prop_patches)
733
- else:
734
- new_properties.append(prop)
735
- return patches, new_properties
736
-
737
- def _process_property(
738
- self, prop: TaskPropertyCreate, existing_data_rows: TaskPropertyData
739
- ) -> list | None:
740
- for interval in existing_data_rows.data:
741
- if interval.interval_combination != prop.interval_combination:
742
- continue
743
-
744
- for trial in interval.trials:
745
- if trial.trial_number != prop.trial_number:
746
- continue
747
-
748
- trial_patches = self._process_trial(trial, prop)
749
- if trial_patches:
750
- return trial_patches
751
-
752
- return None
753
-
754
- def _process_trial(self, trial: Trial, prop: TaskPropertyCreate) -> list | None:
755
- for data_column in trial.data_columns:
756
- if (
757
- data_column.data_column_unique_id
758
- == f"{prop.data_column.data_column_id}#{prop.data_column.column_sequence}"
759
- and data_column.property_data is not None
760
- ):
761
- if data_column.property_data.value == prop.value:
762
- # No need to update this value
763
- return None
764
- return [
765
- PropertyDataPatchDatum(
766
- id=data_column.property_data.id,
767
- operation=PatchOperation.UPDATE,
768
- attribute="value",
769
- new_value=prop.value,
770
- old_value=data_column.property_data.value,
771
- )
772
- ]
773
-
774
- return None
775
-
776
- ################### Methods to support calculated value patches ##################
777
-
778
- def _form_calculated_task_property_patches(
779
- self, *, existing_data_rows: TaskPropertyData, properties: list[TaskPropertyCreate]
780
- ):
781
- patches = []
782
- covered_interval_trials = set()
783
- first_row_data_column = existing_data_rows.data[0].trials[0].data_columns
784
- columns_used_in_calculations = self._get_all_columns_used_in_calculations(
785
- first_row_data_column=first_row_data_column
786
- )
787
- for posted_prop in properties:
788
- this_interval_trial = f"{posted_prop.interval_combination}-{posted_prop.trial_number}"
789
- if (
790
- this_interval_trial in covered_interval_trials
791
- or posted_prop.data_column.column_sequence not in columns_used_in_calculations
792
- ):
793
- continue # we don't need to worry about it hence we skip
794
- on_platform_row = self._get_on_platform_row(
795
- existing_data_rows=existing_data_rows,
796
- trial_number=posted_prop.trial_number,
797
- interval_combination=posted_prop.interval_combination,
798
- )
799
- if on_platform_row is not None:
800
- these_patches = self._generate_data_patch_payload(trial=on_platform_row)
801
- patches.extend(these_patches)
802
- covered_interval_trials.add(this_interval_trial)
803
- return patches
804
-
805
- def _get_on_platform_row(
806
- self, *, existing_data_rows: TaskPropertyData, interval_combination: str, trial_number: int
807
- ):
808
- for interval in existing_data_rows.data:
809
- if interval.interval_combination == interval_combination:
810
- for trial in interval.trials:
811
- if trial.trial_number == trial_number:
812
- return trial
813
- return None
814
-
815
- def _get_columns_used_in_calculation(self, *, calculation: str | None, used_columns: set[str]):
816
- if calculation is None:
817
- return used_columns
818
- column_pattern = r"COL\d+"
819
- matches = re.findall(column_pattern, calculation)
820
- used_columns.update(set(matches))
821
- return used_columns
822
-
823
- def _get_all_columns_used_in_calculations(self, *, first_row_data_column: list[PropertyValue]):
824
- used_columns = set()
825
- for calc in [x.calculation for x in first_row_data_column]:
826
- used_columns = self._get_columns_used_in_calculation(
827
- calculation=calc, used_columns=used_columns
828
- )
829
- return used_columns
830
-
831
- def _evaluate_calculation(self, *, calculation: str, column_values: dict) -> float | None:
832
- calculation = calculation.lstrip("=") # Remove '=' at the start of the calculation
833
- try:
834
- if column_values:
835
- # Replace column names with their numeric values in the calculation string.
836
- # Regex ensures COL1 does not accidentally match COL10, etc.
837
- escaped_cols = [re.escape(col) for col in column_values]
838
- pattern = re.compile(rf"\b({'|'.join(escaped_cols)})\b")
839
-
840
- def repl(match: re.Match) -> str:
841
- col = match.group(0)
842
- return str(column_values.get(col, match.group(0)))
843
-
844
- calculation = pattern.sub(repl, calculation)
845
-
846
- calculation = calculation.replace(
847
- "^", "**"
848
- ) # Replace '^' with '**' for exponentiation
849
- # Evaluate the resulting expression
850
- return eval(calculation)
851
- except Exception as e:
852
- logger.info(
853
- f"Error evaluating calculation '{calculation}': {e}. Likely do not have all values needed."
854
- )
855
- return None
856
-
857
- def _generate_data_patch_payload(self, *, trial: Trial) -> list[PropertyDataPatchDatum]:
858
- column_values = {
859
- col.sequence: col.property_data.value
860
- for col in trial.data_columns
861
- if col.property_data is not None
862
- }
863
-
864
- patch_data = []
865
- for column in trial.data_columns:
866
- if column.calculation:
867
- # Evaluate the recalculated value
868
- recalculated_value = self._evaluate_calculation(
869
- calculation=column.calculation, column_values=column_values
870
- )
871
- if recalculated_value is not None:
872
- # Determine whether this is an ADD or UPDATE operation
873
- if column.property_data.value is None: # No existing value
874
- patch_data.append(
875
- PropertyDataPatchDatum(
876
- id=column.property_data.id,
877
- operation=PatchOperation.ADD,
878
- attribute="value",
879
- new_value=recalculated_value,
880
- old_value=None,
881
- )
882
- )
883
- elif str(column.property_data.value) != str(
884
- recalculated_value
885
- ): # Existing value differs
886
- patch_data.append(
887
- PropertyDataPatchDatum(
888
- id=column.property_data.id,
889
- operation=PatchOperation.UPDATE,
890
- attribute="value",
891
- new_value=recalculated_value,
892
- old_value=column.property_data.value,
893
- )
894
- )
895
-
896
- return patch_data
897
-
898
1002
  @validate_call
899
1003
  def search(
900
1004
  self,