Skip to content

variable

descriptor_any_type

DescriptorAnyType

Bases: DescriptorBase

A Descriptor for any type that does not fit the other Descriptors.

Should be avoided when possible. It was created to hold the symmetry operations used in the SpaceGroup class of EasyCrystallography.

Source code in src/easyscience/variable/descriptor_any_type.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class DescriptorAnyType(DescriptorBase):
    """A `Descriptor` for any type that does not fit the other
    Descriptors.

    Should be avoided when possible. It was created to hold the symmetry
    operations used in the SpaceGroup class of EasyCrystallography.
    """

    def __init__(
        self,
        name: str,
        value: Any,
        unique_name: Optional[str] = None,
        description: Optional[str] = None,
        url: Optional[str] = None,
        display_name: Optional[str] = None,
        parent: Optional[Any] = None,
    ):
        """Constructor for the DescriptorAnyType class.

        param name: Name of the descriptor
        param value: Value of the descriptor
        param description: Description of the descriptor
        param url: URL of the descriptor
        param display_name: Display name of the descriptor
        param parent: Parent of the descriptor
        .. note:: Undo/Redo functionality is implemented for the attributes `variance`, `error`, `unit` and `value`.
        """

        self._value = value

        super().__init__(
            name=name,
            unique_name=unique_name,
            description=description,
            url=url,
            display_name=display_name,
            parent=parent,
        )

    @property
    def value(self) -> numbers.Number:
        """Get the value.

        :return: Value of self.
        """
        return self._value

    @value.setter
    @property_stack
    def value(self, value: Union[list, np.ndarray]) -> None:
        """Set the value of self.

        :param value: New value for the DescriptorAnyType.
        """
        self._value = value

    def __copy__(self) -> DescriptorAnyType:
        return super().__copy__()

    def __repr__(self) -> str:
        """Return a string representation of the DescriptorAnyType,
        showing its name and value.
        """

        if hasattr(self._value, '__repr__'):
            value_repr = repr(self._value)
        else:
            value_repr = type(self._value)

        return f"<{self.__class__.__name__} '{self._name}': {value_repr}>"

    def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
        raw_dict = super().as_dict(skip=skip)
        raw_dict['value'] = self._value
        return raw_dict

__init__(name, value, unique_name=None, description=None, url=None, display_name=None, parent=None)

Constructor for the DescriptorAnyType class.

param name: Name of the descriptor param value: Value of the descriptor param description: Description of the descriptor param url: URL of the descriptor param display_name: Display name of the descriptor param parent: Parent of the descriptor .. note:: Undo/Redo functionality is implemented for the attributes variance, error, unit and value.

Source code in src/easyscience/variable/descriptor_any_type.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def __init__(
    self,
    name: str,
    value: Any,
    unique_name: Optional[str] = None,
    description: Optional[str] = None,
    url: Optional[str] = None,
    display_name: Optional[str] = None,
    parent: Optional[Any] = None,
):
    """Constructor for the DescriptorAnyType class.

    param name: Name of the descriptor
    param value: Value of the descriptor
    param description: Description of the descriptor
    param url: URL of the descriptor
    param display_name: Display name of the descriptor
    param parent: Parent of the descriptor
    .. note:: Undo/Redo functionality is implemented for the attributes `variance`, `error`, `unit` and `value`.
    """

    self._value = value

    super().__init__(
        name=name,
        unique_name=unique_name,
        description=description,
        url=url,
        display_name=display_name,
        parent=parent,
    )

__repr__()

Return a string representation of the DescriptorAnyType, showing its name and value.

Source code in src/easyscience/variable/descriptor_any_type.py
80
81
82
83
84
85
86
87
88
89
90
def __repr__(self) -> str:
    """Return a string representation of the DescriptorAnyType,
    showing its name and value.
    """

    if hasattr(self._value, '__repr__'):
        value_repr = repr(self._value)
    else:
        value_repr = type(self._value)

    return f"<{self.__class__.__name__} '{self._name}': {value_repr}>"

value property writable

Get the value.

:return: Value of self.

descriptor_array

DescriptorArray

Bases: DescriptorBase

A Descriptor for Array values with units.

The internal representation is a scipp array.

Source code in src/easyscience/variable/descriptor_array.py
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
class DescriptorArray(DescriptorBase):
    """A `Descriptor` for Array values with units.

    The internal representation is a scipp array.
    """

    def __init__(
        self,
        name: str,
        value: Union[list, np.ndarray],
        unit: Optional[Union[str, sc.Unit]] = '',
        variance: Optional[Union[list, np.ndarray]] = None,
        unique_name: Optional[str] = None,
        description: Optional[str] = None,
        url: Optional[str] = None,
        display_name: Optional[str] = None,
        parent: Optional[Any] = None,
        dimensions: Optional[list] = None,
    ):
        """Constructor for the DescriptorArray class.

        param name: Name of the descriptor
        param value: List containing the values of the descriptor
        param unit: Unit of the descriptor
        param variance: Variances of the descriptor
        param description: Description of the descriptor
        param url: URL of the descriptor
        param display_name: Display name of the descriptor
        param parent: Parent of the descriptor
        param dimensions: List of dimensions to pass to scipp. Will be autogenerated if not supplied.
        .. note:: Undo/Redo functionality is implemented for the attributes `variance`, `error`, `unit` and `value`.
        """

        if not isinstance(value, (list, np.ndarray)):
            raise TypeError(f'{value=} must be a list or numpy array.')
        if isinstance(value, list):
            value = np.array(value)  # Convert to numpy array for consistent handling.
        value = np.astype(value, 'float')

        if variance is not None:
            if not isinstance(variance, (list, np.ndarray)):
                raise TypeError(f'{variance=} must be a list or numpy array if provided.')
            if isinstance(variance, list):
                variance = np.array(variance)  # Convert to numpy array for consistent handling.
            if variance.shape != value.shape:
                raise ValueError(f'{variance=} must have the same shape as {value=}.')
            if not np.all(variance >= 0):
                raise ValueError(f'{variance=} must only contain non-negative values.')
            variance = np.astype(variance, 'float')

        if not isinstance(unit, sc.Unit) and not isinstance(unit, str):
            raise TypeError(
                f'{unit=} must be a scipp unit or a string representing a valid scipp unit'
            )

        if dimensions is None:
            # Autogenerate dimensions if not supplied
            dimensions = ['dim' + str(i) for i in range(len(value.shape))]
        if not len(dimensions) == len(value.shape):
            raise ValueError(
                f'Length of dimensions ({dimensions=}) does not match length of value {value=}.'
            )
        self._dimensions = dimensions

        try:
            # Convert value and variance to floats
            # for optimization everything must be floats
            self._array = sc.array(dims=dimensions, values=value, unit=unit, variances=variance)
        except Exception as message:
            raise UnitError(message)
            # TODO: handle 1xn and nx1 arrays

        super().__init__(
            name=name,
            unique_name=unique_name,
            description=description,
            url=url,
            display_name=display_name,
            parent=parent,
        )

        # Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency.
        if self.unit is not None:
            self.convert_unit(self._base_unit())

    @classmethod
    def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorArray:
        """Create a DescriptorArray from a scipp array.

        :param name: Name of the descriptor
        :param full_value: Value of the descriptor as a scipp variable
        :param kwargs: Additional parameters for the descriptor
        :return: DescriptorArray
        """
        if not isinstance(full_value, Variable):
            raise TypeError(f'{full_value=} must be a scipp array')
        return cls(
            name=name,
            value=full_value.values,
            unit=full_value.unit,
            variance=full_value.variances,
            dimensions=full_value.dims,
            **kwargs,
        )

    @property
    def full_value(self) -> Variable:
        """Get the value of self as a scipp array. This should be usable
        for most cases.

        :return: Value of self with unit.
        """
        return self._array

    @full_value.setter
    def full_value(self, full_value: Variable) -> None:
        raise AttributeError(
            f'Full_value is read-only. Change the value and variance separately. Or create a new {self.__class__.__name__}.'
        )

    @property
    def value(self) -> numbers.Number:
        """Get the value without units. The Scipp array can be obtained
        from `obj.full_value`.

        :return: Value of self without unit.
        """
        return self._array.values

    @value.setter
    @property_stack
    def value(self, value: Union[list, np.ndarray]) -> None:
        """Set the value of self. Ensures the input is an array and
        matches the shape of the existing array. The full value can be
        obtained from `obj.full_value`.

        :param value: New value for the DescriptorArray, must be a list
            or numpy array.
        """
        if not isinstance(value, (list, np.ndarray)):
            raise TypeError(f'{value=} must be a list or numpy array.')
        if isinstance(value, list):
            value = np.array(value)  # Convert lists to numpy arrays for consistent handling.

        if value.shape != self._array.values.shape:
            raise ValueError(f'{value=} must have the same shape as the existing array values.')

        # Values must be floats for optimization
        self._array.values = value.astype('float')

    @property
    def dimensions(self) -> list:
        """Get the dimensions used for the underlying scipp array.

        :return: dimensions of self.
        """
        return self._dimensions

    @dimensions.setter
    def dimensions(self, dimensions: Union[list]) -> None:
        """Set the dimensions of self. Ensures that the input has a
        shape compatible with self.full_value.

        :param value: list of dimensions.
        """
        if not isinstance(dimensions, (list, np.ndarray)):
            raise TypeError(f'{dimensions=} must be a list or numpy array.')

        if len(dimensions) != len(self._dimensions):
            raise ValueError(f'{dimensions=} must have the same shape as the existing dims')

        self._dimensions = dimensions
        # Also rename the dims of the scipp array
        rename_dict = {
            old_dim: new_dim for (old_dim, new_dim) in zip(self.full_value.dims, dimensions)
        }
        renamed_array = self._array.rename_dims(rename_dict)
        self._array = renamed_array

    @property
    def unit(self) -> str:
        """Get the unit.

        :return: Unit as a string.
        """
        return str(self._array.unit)

    @unit.setter
    def unit(self, unit_str: str) -> None:
        raise AttributeError(
            (
                f'Unit is read-only. Use convert_unit to change the unit between allowed types '
                f'or create a new {self.__class__.__name__} with the desired unit.'
            )
        )  # noqa: E501

    @property
    def variance(self) -> np.ndarray:
        """Get the variance as a Numpy ndarray.

        :return: variance.
        """
        return self._array.variances

    @variance.setter
    @property_stack
    def variance(self, variance: Union[list, np.ndarray]) -> None:
        """Set the variance of self. Ensures the input is an array and
        matches the shape of the existing values.

        :param variance: New variance for the DescriptorArray, must be a
            list or numpy array.
        """
        if variance is not None:
            if not isinstance(variance, (list, np.ndarray)):
                raise TypeError(f'{variance=} must be a list or numpy array.')
            if isinstance(variance, list):
                variance = np.array(
                    variance
                )  # Convert lists to numpy arrays for consistent handling.

            if variance.shape != self._array.shape:
                raise ValueError(f'{variance=} must have the same shape as the array values.')

            if not np.all(variance >= 0):
                raise ValueError(f'{variance=} must only contain non-negative values.')

        # Values must be floats for optimization
        self._array.variances = variance.astype('float')

    @property
    def error(self) -> Optional[np.ndarray]:
        """The standard deviations, calculated as the square root of
        variances.

        :return: A numpy array of standard deviations, or None if
            variances are not set.
        """
        if self._array.variances is None:
            return None
        return np.sqrt(self._array.variances)

    @error.setter
    @property_stack
    def error(self, error: Union[list, np.ndarray]) -> None:
        """Set the standard deviation for the parameter, which updates
        the variances.

        :param error: A list or numpy array of standard deviations.
        """
        if error is not None:
            if not isinstance(error, (list, np.ndarray)):
                raise TypeError(f'{error=} must be a list or numpy array.')
            if isinstance(error, list):
                error = np.array(error)  # Convert lists to numpy arrays for consistent handling.

            if error.shape != self._array.values.shape:
                raise ValueError(f'{error=} must have the same shape as the array values.')

            if not np.all(error >= 0):
                raise ValueError(f'{error=} must only contain non-negative values.')

            # Update variances as the square of the errors
            self._array.variances = error**2
        else:
            self._array.variances = None

    def convert_unit(self, unit_str: str) -> None:
        """Convert the value from one unit system to another.

        :param unit_str: New unit in string form
        """
        if not isinstance(unit_str, str):
            raise TypeError(f'{unit_str=} must be a string representing a valid scipp unit')
        new_unit = sc.Unit(unit_str)

        # Save the current state for undo/redo
        old_array = self._array

        # Perform the unit conversion
        try:
            new_array = self._array.to(unit=new_unit)
        except Exception as e:
            raise UnitError(f'Failed to convert unit: {e}') from e

        # Define the setter function for the undo stack
        def set_array(obj, scalar):
            obj._array = scalar

        # Push to undo stack
        self._global_object.stack.push(
            PropertyStack(
                self, set_array, old_array, new_array, text=f'Convert unit to {unit_str}'
            )
        )

        # Update the array
        self._array = new_array

    def __copy__(self) -> DescriptorArray:
        """Return a copy of the current DescriptorArray."""
        return super().__copy__()

    def __repr__(self) -> str:
        """Return a string representation of the DescriptorArray,
        showing its name, value, variance, and unit.

        Large arrays are summarized for brevity.
        """
        # Base string with name
        string = f"<{self.__class__.__name__} '{self._name}': "

        # Summarize array values
        values_summary = np.array2string(
            self._array.values,
            precision=4,
            threshold=10,  # Show full array if <=10 elements, else summarize
            edgeitems=3,  # Show first and last 3 elements for large arrays
        )
        string += f'values={values_summary}'

        # Add errors if they exists
        if self._array.variances is not None:
            errors_summary = np.array2string(
                self.error,
                precision=4,
                threshold=10,
                edgeitems=3,
            )
            string += f', errors={errors_summary}'

        # Add unit
        obj_unit = str(self._array.unit)
        if obj_unit and obj_unit != 'dimensionless':
            string += f', unit={obj_unit}'

        string += '>'
        string = string.replace('\n', ',')
        return string

    def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
        """Dict representation of the current DescriptorArray.

        The dict contains the value, unit and variances, in addition to
        the properties of DescriptorBase.
        """
        raw_dict = super().as_dict(skip=skip)
        raw_dict['value'] = self._array.values
        raw_dict['unit'] = str(self._array.unit)
        raw_dict['variance'] = self._array.variances
        raw_dict['dimensions'] = self._array.dims
        return raw_dict

    def _apply_operation(
        self,
        other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number],
        operation: Callable,
        units_must_match: bool = True,
    ) -> DescriptorArray:
        """Perform element-wise operations with another
        DescriptorNumber, DescriptorArray, list, or number.

        :param other: The object to operate on. Must be a
            DescriptorArray or DescriptorNumber with compatible units,
            or a list with the same shape if the DescriptorArray is
            dimensionless.
        :param operation: The operation to perform
        :return: A new DescriptorArray representing the result of the
            operation.
        """
        if isinstance(other, numbers.Number):
            # Does not need to be dimensionless for multiplication and division
            if self.unit not in [None, 'dimensionless'] and units_must_match:
                raise UnitError('Numbers can only be used together with dimensionless values')
            new_full_value = operation(self.full_value, other)

        elif isinstance(other, list):
            if self.unit not in [None, 'dimensionless'] and units_must_match:
                raise UnitError('Operations with lists are only allowed for dimensionless values')

            # Ensure dimensions match
            if np.shape(other) != self._array.values.shape:
                raise ValueError(
                    f'Shape of {other=} must match the shape of DescriptorArray values'
                )

            other = sc.array(dims=self._array.dims, values=other)
            new_full_value = operation(
                self._array, other
            )  # Let scipp handle operation for uncertainty propagation

        elif isinstance(other, DescriptorNumber):
            try:
                other_converted = other.__copy__()
                other_converted.convert_unit(self.unit)
            except UnitError:
                if units_must_match:
                    raise UnitError(
                        f'Values with units {self.unit} and {other.unit} are not compatible'
                    ) from None
            # Operations with a DescriptorNumber that has a variance WILL introduce
            # correlations between the elements of the DescriptorArray.
            # See, https://content.iospress.com/articles/journal-of-neutron-research/jnr220049
            # However, DescriptorArray does not consider the covariance between
            # elements of the array. Hence, the broadcasting is "manually"
            # performed to work around `scipp` and a warning raised to the end user.
            if self._array.variances is not None or other.variance is not None:
                warn(
                    'Correlations introduced by this operation will not be considered.\
                      See https://content.iospress.com/articles/journal-of-neutron-research/jnr220049\
                      for further details',
                    UserWarning,
                )
            # Cheeky copy() of broadcasted scipp array to force scipp to perform the broadcast here
            broadcasted = sc.broadcast(
                other_converted.full_value, dims=self._array.dims, shape=self._array.shape
            ).copy()
            new_full_value = operation(self.full_value, broadcasted)

        elif isinstance(other, DescriptorArray):
            try:
                other_converted = other.__copy__()
                other_converted.convert_unit(self.unit)
            except UnitError:
                if units_must_match:
                    raise UnitError(
                        f'Values with units {self.unit} and {other.unit} are incompatible'
                    ) from None

            # Ensure dimensions match
            if self.full_value.dims != other_converted.full_value.dims:
                raise ValueError(
                    f'Dimensions of the DescriptorArrays do not match: '
                    f'{self.full_value.dims} vs {other_converted.full_value.dims}'
                )

            new_full_value = operation(self.full_value, other_converted.full_value)

        else:
            return NotImplemented

        descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_full_value)
        descriptor_array.name = descriptor_array.unique_name
        return descriptor_array

    def _rapply_operation(
        self,
        other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number],
        operation: Callable,
        units_must_match: bool = True,
    ) -> DescriptorArray:
        """Handle reverse operations for DescriptorArrays,
        DescriptorNumbers, lists, and scalars.

        Ensures unit compatibility when `other` is a DescriptorNumber.
        """

        def reversed_operation(a, b):
            return operation(b, a)

        if isinstance(other, DescriptorNumber):
            # Ensure unit compatibility for DescriptorNumber
            original_unit = self.unit
            try:
                self.convert_unit(other.unit)  # Convert `self` to `other`'s unit
            except UnitError:
                # Only allowed operations with different units are
                # multiplication and division. We try to convert
                # the units for mul/div, but if the conversion
                # fails it's no big deal.
                if units_must_match:
                    raise UnitError(
                        f'Values with units {self.unit} and {other.unit} are incompatible'
                    ) from None
            result = self._apply_operation(other, reversed_operation, units_must_match)
            # Revert `self` to its original unit
            self.convert_unit(original_unit)
            return result
        else:
            # Delegate to operation to __self__ for other types (e.g., list, scalar)
            return self._apply_operation(other, reversed_operation, units_must_match)

    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
        """DescriptorArray does not generally support Numpy array
        functions.

        For example, `np.argwhere(descriptorArray: DescriptorArray)`
        should fail. Modify this function if you want to add such
        functionality.
        """
        return NotImplemented

    def __array_function__(self, func, types, args, kwargs):
        """DescriptorArray does not generally support Numpy array
        functions.

        For example, `np.argwhere(descriptorArray: DescriptorArray)`
        should fail. Modify this function if you want to add such
        functionality.
        """
        return NotImplemented

    def __add__(
        self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]
    ) -> DescriptorArray:
        """Perform element-wise addition with another DescriptorNumber,
        DescriptorArray, list, or number.

        :param other: The object to add. Must be a DescriptorArray or
            DescriptorNumber with compatible units, or a list with the
            same shape if the DescriptorArray is dimensionless, or a
            number.
        :return: A new DescriptorArray representing the result of the
            addition.
        """
        return self._apply_operation(other, operator.add)

    def __radd__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray:
        """Handle reverse addition for DescriptorArrays,
        DescriptorNumbers, lists, and scalars.

        Ensures unit compatibility when `other` is a DescriptorNumber.
        """
        return self._rapply_operation(other, operator.add)

    def __sub__(
        self, other: Union[DescriptorArray, list, np.ndarray, numbers.Number]
    ) -> DescriptorArray:
        """Perform element-wise subtraction with another
        DescriptorArray, list, or number.

        :param other: The object to subtract. Must be a DescriptorArray
            with compatible units, or a list with the same shape if the
            DescriptorArray is dimensionless.
        :return: A new DescriptorArray representing the result of the
            subtraction.
        """
        if isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)):
            # Leverage __neg__ and __add__ for subtraction
            if isinstance(other, list):
                # Use numpy to negate all elements of the list
                value = (-np.array(other)).tolist()
            else:
                value = -other
            return self.__add__(value)
        else:
            return NotImplemented

    def __rsub__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray:
        """Perform element-wise subtraction with another
        DescriptorNumber, list, or number.

        :param other: The object to subtract. Must be a DescriptorArray
            with compatible units, or a list with the same shape if the
            DescriptorArray is dimensionless.
        :return: A new DescriptorArray representing the result of the
            subtraction.
        """
        if isinstance(other, (DescriptorNumber, list, numbers.Number)):
            if isinstance(other, list):
                # Use numpy to negate all elements of the list
                value = (-np.array(other)).tolist()
            else:
                value = -other
            return -(self.__radd__(value))
        else:
            return NotImplemented

    def __mul__(
        self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]
    ) -> DescriptorArray:
        """Perform element-wise multiplication with another
        DescriptorNumber, DescriptorArray, list, or number.

        :param other: The object to multiply. Must be a DescriptorArray
            or DescriptorNumber with compatible units, or a list with
            the same shape if the DescriptorArray is dimensionless.
        :return: A new DescriptorArray representing the result of the
            addition.
        """
        if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)):
            return NotImplemented
        return self._apply_operation(other, operator.mul, units_must_match=False)

    def __rmul__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray:
        """Handle reverse multiplication for DescriptorNumbers, lists,
        and scalars.

        Ensures unit compatibility when `other` is a DescriptorNumber.
        """
        if not isinstance(other, (DescriptorNumber, list, numbers.Number)):
            return NotImplemented
        return self._rapply_operation(other, operator.mul, units_must_match=False)

    def __truediv__(
        self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]
    ) -> DescriptorArray:
        """Perform element-wise division with another DescriptorNumber,
        DescriptorArray, list, or number.

        :param other: The object to use as a denominator. Must be a
            DescriptorArray or DescriptorNumber with compatible units,
            or a list with the same shape if the DescriptorArray is
            dimensionless.
        :return: A new DescriptorArray representing the result of the
            addition.
        """
        if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)):
            return NotImplemented

        if isinstance(other, numbers.Number):
            original_other = other
        elif isinstance(other, list):
            original_other = np.array(other)
        elif isinstance(other, (DescriptorArray, DescriptorNumber)):
            original_other = other.value

        if np.any(original_other == 0):
            raise ZeroDivisionError('Cannot divide by zero')
        return self._apply_operation(other, operator.truediv, units_must_match=False)

    def __rtruediv__(
        self, other: Union[DescriptorNumber, list, numbers.Number]
    ) -> DescriptorArray:
        """Handle reverse division for DescriptorNumbers, lists, and
        scalars.

        Ensures unit compatibility when `other` is a DescriptorNumber.
        """
        if not isinstance(other, (DescriptorNumber, list, numbers.Number)):
            return NotImplemented

        if np.any(self.full_value.values == 0):
            raise ZeroDivisionError('Cannot divide by zero')

        # First use __div__ to compute `self / other`
        # but first converting to the units of other
        inverse_result = self._rapply_operation(other, operator.truediv, units_must_match=False)
        return inverse_result

    def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorArray:
        """Perform element-wise exponentiation with another
        DescriptorNumber or number.

        :param other: The object to use as a denominator. Must be a
            number or DescriptorNumber with no unit or variance.
        :return: A new DescriptorArray representing the result of the
            addition.
        """
        if not isinstance(other, (numbers.Number, DescriptorNumber)):
            return NotImplemented

        if isinstance(other, numbers.Number):
            exponent = other
        elif isinstance(other, DescriptorNumber):
            if other.unit != 'dimensionless':
                raise UnitError('Exponents must be dimensionless')
            if other.variance is not None:
                raise ValueError('Exponents must not have variance')
            exponent = other.value
        else:
            return NotImplemented
        try:
            new_value = self.full_value**exponent
        except Exception as message:
            raise message from None
        if np.any(np.isnan(new_value.values)):
            raise ValueError('The result of the exponentiation is not a number')
        descriptor_number = DescriptorArray.from_scipp(name=self.name, full_value=new_value)
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __rpow__(self, other: numbers.Number):
        """Defers reverse pow with a descriptor array, `a ** array`.

        Exponentiation with regards to an array does not make sense, and
        is not implemented.
        """
        raise ValueError('Raising a value to the power of an array does not make sense.')

    def __neg__(self) -> DescriptorArray:
        """Negate all values in the DescriptorArray."""
        new_value = -self.full_value
        descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_value)
        descriptor_array.name = descriptor_array.unique_name
        return descriptor_array

    def __abs__(self) -> DescriptorArray:
        """Replace all elements in the DescriptorArray with their
        absolute values.

        Note that this is different from the norm of the
        DescriptorArray.
        """
        new_value = abs(self.full_value)
        descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_value)
        descriptor_array.name = descriptor_array.unique_name
        return descriptor_array

    def __getitem__(self, a) -> DescriptorArray:
        """Slice using scipp syntax.

        Defer slicing to scipp.
        """
        descriptor = DescriptorArray.from_scipp(
            name=self.name, full_value=self.full_value.__getitem__(a)
        )
        descriptor.name = descriptor.unique_name
        return descriptor

    def __delitem__(self, a):
        """Defer slicing to scipp.

        This should fail, since scipp does not support __delitem__.
        """
        return self.full_value.__delitem__(a)

    def __setitem__(self, a, b: Union[numbers.Number, list, DescriptorNumber, DescriptorArray]):
        """__setitem via slice is not allowed, since we currently do not
        give back a view to the DescriptorArray upon calling
        __getitem__.
        """
        raise AttributeError(
            f'{self.__class__.__name__} cannot be edited via slicing. Edit the underlying scipp\
                    array via the `full_value` property, or create a\
                    new {self.__class__.__name__}.'
        )

    def trace(
        self, dimension1: Optional[str] = None, dimension2: Optional[str] = None
    ) -> Union[DescriptorArray, DescriptorNumber]:
        """Computes the trace over the descriptor array. The submatrix
        defined `dimension1` and `dimension2` must be square. For a rank
        `k` tensor, the trace will run over the firs two dimensions,
        resulting in a rank `k-2` tensor.

        :param dimension1, dimension2: First and second dimension to perform trace over. Must be in `self.dimensions`.
            If not defined, the trace will be taken over the first two dimensions.
        """
        if (dimension1 is not None and dimension2 is None) or (
            dimension1 is None and dimension2 is not None
        ):
            raise ValueError('Either both or none of `dimension1` and `dimension2` must be set.')

        if dimension1 is not None and dimension2 is not None:
            if dimension1 == dimension2:
                raise ValueError(f'`{dimension1=}` and `{dimension2=}` must be different.')

            axes = []
            for dim in (dimension1, dimension2):
                if dim not in self.dimensions:
                    raise ValueError(f'Dimension {dim=} does not exist in `self.dimensions`.')
                index = self.dimensions.index(dim)
                axes.append(index)
            remaining_dimensions = [
                dim for dim in self.dimensions if dim not in (dimension1, dimension2)
            ]
        else:
            # Take the first two dimensions
            axes = (0, 1)
            # Pick out the remaining dims
            remaining_dimensions = self.dimensions[2:]

        trace_value = np.trace(self.value, axis1=axes[0], axis2=axes[1])
        trace_variance = (
            np.trace(self.variance, axis1=axes[0], axis2=axes[1])
            if self.variance is not None
            else None
        )
        # The trace reduces a rank k tensor to a k-2.
        if remaining_dimensions == []:
            # No remaining dimensions; the trace is a scalar
            trace = sc.scalar(value=trace_value, unit=self.unit, variance=trace_variance)
            constructor = DescriptorNumber.from_scipp
        else:
            # Else, the result is some array
            trace = sc.array(
                dims=remaining_dimensions,
                values=trace_value,
                unit=self.unit,
                variances=trace_variance,
            )
            constructor = DescriptorArray.from_scipp

        descriptor = constructor(name=self.name, full_value=trace)
        descriptor.name = descriptor.unique_name
        return descriptor

    def sum(
        self, dim: Optional[Union[str, list]] = None
    ) -> Union[DescriptorArray, DescriptorNumber]:
        """Uses scipp to sum over the requested dims.

        :param dim: The dim(s) in the scipp array to sum over. If `None`, will sum over all dims.
        """
        new_full_value = self.full_value.sum(dim=dim)

        # If fully reduced the result will be a DescriptorNumber,
        # otherwise a DescriptorArray
        if dim is None:
            constructor = DescriptorNumber.from_scipp
        else:
            constructor = DescriptorArray.from_scipp

        descriptor = constructor(name=self.name, full_value=new_full_value)
        descriptor.name = descriptor.unique_name
        return descriptor

    # This is to be implemented at a later time
    # def __matmul__(self, other: [DescriptorArray, list]) -> DescriptorArray:
    #     """
    #     Perform matrix multiplication with with another DesciptorArray or list.

    #     :param other: The object to use as a denominator. Must be a DescriptorArray
    #                 or a list, of compatible shape.
    #     :return: A new DescriptorArray representing the result of the addition.
    #     """
    #     if not isinstance(other, (DescriptorArray, list)):
    #         return NotImplemented

    #     if isinstance(other, DescriptorArray):
    #         shape = other.full_value.shape
    #     elif isinstance(other, list):
    #         shape = np.shape(other)

    #     # Dimensions must match for matrix multiplication
    #     if shape[0] != self._array.values.shape[-1]:
    #         raise ValueError(f"Last dimension of {other=} must match the first dimension of DescriptorArray values")
    #
    #     other = sc.array(dims=self._array.dims, values=other)
    #     new_full_value = operation(self._array, other)  # Let scipp handle operation for uncertainty propagation

    def _base_unit(self) -> str:
        """Returns the base unit of the current array.

        For example, if the unit is `100m`, returns `m`.
        """
        string = str(self._array.unit)
        for i, letter in enumerate(string):
            if letter == 'e':
                if string[i : i + 2] not in ['e+', 'e-']:
                    return string[i:]
            elif letter not in ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '+', '-']:
                return string[i:]
        return ''

__abs__()

Replace all elements in the DescriptorArray with their absolute values.

Note that this is different from the norm of the DescriptorArray.

Source code in src/easyscience/variable/descriptor_array.py
715
716
717
718
719
720
721
722
723
724
725
def __abs__(self) -> DescriptorArray:
    """Replace all elements in the DescriptorArray with their
    absolute values.

    Note that this is different from the norm of the
    DescriptorArray.
    """
    new_value = abs(self.full_value)
    descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_value)
    descriptor_array.name = descriptor_array.unique_name
    return descriptor_array

__add__(other)

Perform element-wise addition with another DescriptorNumber, DescriptorArray, list, or number.

:param other: The object to add. Must be a DescriptorArray or DescriptorNumber with compatible units, or a list with the same shape if the DescriptorArray is dimensionless, or a number. :return: A new DescriptorArray representing the result of the addition.

Source code in src/easyscience/variable/descriptor_array.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
def __add__(
    self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]
) -> DescriptorArray:
    """Perform element-wise addition with another DescriptorNumber,
    DescriptorArray, list, or number.

    :param other: The object to add. Must be a DescriptorArray or
        DescriptorNumber with compatible units, or a list with the
        same shape if the DescriptorArray is dimensionless, or a
        number.
    :return: A new DescriptorArray representing the result of the
        addition.
    """
    return self._apply_operation(other, operator.add)

__array_function__(func, types, args, kwargs)

DescriptorArray does not generally support Numpy array functions.

For example, np.argwhere(descriptorArray: DescriptorArray) should fail. Modify this function if you want to add such functionality.

Source code in src/easyscience/variable/descriptor_array.py
520
521
522
523
524
525
526
527
528
def __array_function__(self, func, types, args, kwargs):
    """DescriptorArray does not generally support Numpy array
    functions.

    For example, `np.argwhere(descriptorArray: DescriptorArray)`
    should fail. Modify this function if you want to add such
    functionality.
    """
    return NotImplemented

__array_ufunc__(ufunc, method, *inputs, **kwargs)

DescriptorArray does not generally support Numpy array functions.

For example, np.argwhere(descriptorArray: DescriptorArray) should fail. Modify this function if you want to add such functionality.

Source code in src/easyscience/variable/descriptor_array.py
510
511
512
513
514
515
516
517
518
def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
    """DescriptorArray does not generally support Numpy array
    functions.

    For example, `np.argwhere(descriptorArray: DescriptorArray)`
    should fail. Modify this function if you want to add such
    functionality.
    """
    return NotImplemented

__copy__()

Return a copy of the current DescriptorArray.

Source code in src/easyscience/variable/descriptor_array.py
327
328
329
def __copy__(self) -> DescriptorArray:
    """Return a copy of the current DescriptorArray."""
    return super().__copy__()

__delitem__(a)

Defer slicing to scipp.

This should fail, since scipp does not support delitem.

Source code in src/easyscience/variable/descriptor_array.py
738
739
740
741
742
743
def __delitem__(self, a):
    """Defer slicing to scipp.

    This should fail, since scipp does not support __delitem__.
    """
    return self.full_value.__delitem__(a)

__getitem__(a)

Slice using scipp syntax.

Defer slicing to scipp.

Source code in src/easyscience/variable/descriptor_array.py
727
728
729
730
731
732
733
734
735
736
def __getitem__(self, a) -> DescriptorArray:
    """Slice using scipp syntax.

    Defer slicing to scipp.
    """
    descriptor = DescriptorArray.from_scipp(
        name=self.name, full_value=self.full_value.__getitem__(a)
    )
    descriptor.name = descriptor.unique_name
    return descriptor

__init__(name, value, unit='', variance=None, unique_name=None, description=None, url=None, display_name=None, parent=None, dimensions=None)

Constructor for the DescriptorArray class.

param name: Name of the descriptor param value: List containing the values of the descriptor param unit: Unit of the descriptor param variance: Variances of the descriptor param description: Description of the descriptor param url: URL of the descriptor param display_name: Display name of the descriptor param parent: Parent of the descriptor param dimensions: List of dimensions to pass to scipp. Will be autogenerated if not supplied. .. note:: Undo/Redo functionality is implemented for the attributes variance, error, unit and value.

Source code in src/easyscience/variable/descriptor_array.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def __init__(
    self,
    name: str,
    value: Union[list, np.ndarray],
    unit: Optional[Union[str, sc.Unit]] = '',
    variance: Optional[Union[list, np.ndarray]] = None,
    unique_name: Optional[str] = None,
    description: Optional[str] = None,
    url: Optional[str] = None,
    display_name: Optional[str] = None,
    parent: Optional[Any] = None,
    dimensions: Optional[list] = None,
):
    """Constructor for the DescriptorArray class.

    param name: Name of the descriptor
    param value: List containing the values of the descriptor
    param unit: Unit of the descriptor
    param variance: Variances of the descriptor
    param description: Description of the descriptor
    param url: URL of the descriptor
    param display_name: Display name of the descriptor
    param parent: Parent of the descriptor
    param dimensions: List of dimensions to pass to scipp. Will be autogenerated if not supplied.
    .. note:: Undo/Redo functionality is implemented for the attributes `variance`, `error`, `unit` and `value`.
    """

    if not isinstance(value, (list, np.ndarray)):
        raise TypeError(f'{value=} must be a list or numpy array.')
    if isinstance(value, list):
        value = np.array(value)  # Convert to numpy array for consistent handling.
    value = np.astype(value, 'float')

    if variance is not None:
        if not isinstance(variance, (list, np.ndarray)):
            raise TypeError(f'{variance=} must be a list or numpy array if provided.')
        if isinstance(variance, list):
            variance = np.array(variance)  # Convert to numpy array for consistent handling.
        if variance.shape != value.shape:
            raise ValueError(f'{variance=} must have the same shape as {value=}.')
        if not np.all(variance >= 0):
            raise ValueError(f'{variance=} must only contain non-negative values.')
        variance = np.astype(variance, 'float')

    if not isinstance(unit, sc.Unit) and not isinstance(unit, str):
        raise TypeError(
            f'{unit=} must be a scipp unit or a string representing a valid scipp unit'
        )

    if dimensions is None:
        # Autogenerate dimensions if not supplied
        dimensions = ['dim' + str(i) for i in range(len(value.shape))]
    if not len(dimensions) == len(value.shape):
        raise ValueError(
            f'Length of dimensions ({dimensions=}) does not match length of value {value=}.'
        )
    self._dimensions = dimensions

    try:
        # Convert value and variance to floats
        # for optimization everything must be floats
        self._array = sc.array(dims=dimensions, values=value, unit=unit, variances=variance)
    except Exception as message:
        raise UnitError(message)
        # TODO: handle 1xn and nx1 arrays

    super().__init__(
        name=name,
        unique_name=unique_name,
        description=description,
        url=url,
        display_name=display_name,
        parent=parent,
    )

    # Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency.
    if self.unit is not None:
        self.convert_unit(self._base_unit())

__mul__(other)

Perform element-wise multiplication with another DescriptorNumber, DescriptorArray, list, or number.

:param other: The object to multiply. Must be a DescriptorArray or DescriptorNumber with compatible units, or a list with the same shape if the DescriptorArray is dimensionless. :return: A new DescriptorArray representing the result of the addition.

Source code in src/easyscience/variable/descriptor_array.py
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
def __mul__(
    self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]
) -> DescriptorArray:
    """Perform element-wise multiplication with another
    DescriptorNumber, DescriptorArray, list, or number.

    :param other: The object to multiply. Must be a DescriptorArray
        or DescriptorNumber with compatible units, or a list with
        the same shape if the DescriptorArray is dimensionless.
    :return: A new DescriptorArray representing the result of the
        addition.
    """
    if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)):
        return NotImplemented
    return self._apply_operation(other, operator.mul, units_must_match=False)

__neg__()

Negate all values in the DescriptorArray.

Source code in src/easyscience/variable/descriptor_array.py
708
709
710
711
712
713
def __neg__(self) -> DescriptorArray:
    """Negate all values in the DescriptorArray."""
    new_value = -self.full_value
    descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_value)
    descriptor_array.name = descriptor_array.unique_name
    return descriptor_array

__pow__(other)

Perform element-wise exponentiation with another DescriptorNumber or number.

:param other: The object to use as a denominator. Must be a number or DescriptorNumber with no unit or variance. :return: A new DescriptorArray representing the result of the addition.

Source code in src/easyscience/variable/descriptor_array.py
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorArray:
    """Perform element-wise exponentiation with another
    DescriptorNumber or number.

    :param other: The object to use as a denominator. Must be a
        number or DescriptorNumber with no unit or variance.
    :return: A new DescriptorArray representing the result of the
        addition.
    """
    if not isinstance(other, (numbers.Number, DescriptorNumber)):
        return NotImplemented

    if isinstance(other, numbers.Number):
        exponent = other
    elif isinstance(other, DescriptorNumber):
        if other.unit != 'dimensionless':
            raise UnitError('Exponents must be dimensionless')
        if other.variance is not None:
            raise ValueError('Exponents must not have variance')
        exponent = other.value
    else:
        return NotImplemented
    try:
        new_value = self.full_value**exponent
    except Exception as message:
        raise message from None
    if np.any(np.isnan(new_value.values)):
        raise ValueError('The result of the exponentiation is not a number')
    descriptor_number = DescriptorArray.from_scipp(name=self.name, full_value=new_value)
    descriptor_number.name = descriptor_number.unique_name
    return descriptor_number

__radd__(other)

Handle reverse addition for DescriptorArrays, DescriptorNumbers, lists, and scalars.

Ensures unit compatibility when other is a DescriptorNumber.

Source code in src/easyscience/variable/descriptor_array.py
545
546
547
548
549
550
551
def __radd__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray:
    """Handle reverse addition for DescriptorArrays,
    DescriptorNumbers, lists, and scalars.

    Ensures unit compatibility when `other` is a DescriptorNumber.
    """
    return self._rapply_operation(other, operator.add)

__repr__()

Return a string representation of the DescriptorArray, showing its name, value, variance, and unit.

Large arrays are summarized for brevity.

Source code in src/easyscience/variable/descriptor_array.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def __repr__(self) -> str:
    """Return a string representation of the DescriptorArray,
    showing its name, value, variance, and unit.

    Large arrays are summarized for brevity.
    """
    # Base string with name
    string = f"<{self.__class__.__name__} '{self._name}': "

    # Summarize array values
    values_summary = np.array2string(
        self._array.values,
        precision=4,
        threshold=10,  # Show full array if <=10 elements, else summarize
        edgeitems=3,  # Show first and last 3 elements for large arrays
    )
    string += f'values={values_summary}'

    # Add errors if they exists
    if self._array.variances is not None:
        errors_summary = np.array2string(
            self.error,
            precision=4,
            threshold=10,
            edgeitems=3,
        )
        string += f', errors={errors_summary}'

    # Add unit
    obj_unit = str(self._array.unit)
    if obj_unit and obj_unit != 'dimensionless':
        string += f', unit={obj_unit}'

    string += '>'
    string = string.replace('\n', ',')
    return string

__rmul__(other)

Handle reverse multiplication for DescriptorNumbers, lists, and scalars.

Ensures unit compatibility when other is a DescriptorNumber.

Source code in src/easyscience/variable/descriptor_array.py
612
613
614
615
616
617
618
619
620
def __rmul__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray:
    """Handle reverse multiplication for DescriptorNumbers, lists,
    and scalars.

    Ensures unit compatibility when `other` is a DescriptorNumber.
    """
    if not isinstance(other, (DescriptorNumber, list, numbers.Number)):
        return NotImplemented
    return self._rapply_operation(other, operator.mul, units_must_match=False)

__rpow__(other)

Defers reverse pow with a descriptor array, a ** array.

Exponentiation with regards to an array does not make sense, and is not implemented.

Source code in src/easyscience/variable/descriptor_array.py
700
701
702
703
704
705
706
def __rpow__(self, other: numbers.Number):
    """Defers reverse pow with a descriptor array, `a ** array`.

    Exponentiation with regards to an array does not make sense, and
    is not implemented.
    """
    raise ValueError('Raising a value to the power of an array does not make sense.')

__rsub__(other)

Perform element-wise subtraction with another DescriptorNumber, list, or number.

:param other: The object to subtract. Must be a DescriptorArray with compatible units, or a list with the same shape if the DescriptorArray is dimensionless. :return: A new DescriptorArray representing the result of the subtraction.

Source code in src/easyscience/variable/descriptor_array.py
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
def __rsub__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray:
    """Perform element-wise subtraction with another
    DescriptorNumber, list, or number.

    :param other: The object to subtract. Must be a DescriptorArray
        with compatible units, or a list with the same shape if the
        DescriptorArray is dimensionless.
    :return: A new DescriptorArray representing the result of the
        subtraction.
    """
    if isinstance(other, (DescriptorNumber, list, numbers.Number)):
        if isinstance(other, list):
            # Use numpy to negate all elements of the list
            value = (-np.array(other)).tolist()
        else:
            value = -other
        return -(self.__radd__(value))
    else:
        return NotImplemented

__rtruediv__(other)

Handle reverse division for DescriptorNumbers, lists, and scalars.

Ensures unit compatibility when other is a DescriptorNumber.

Source code in src/easyscience/variable/descriptor_array.py
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
def __rtruediv__(
    self, other: Union[DescriptorNumber, list, numbers.Number]
) -> DescriptorArray:
    """Handle reverse division for DescriptorNumbers, lists, and
    scalars.

    Ensures unit compatibility when `other` is a DescriptorNumber.
    """
    if not isinstance(other, (DescriptorNumber, list, numbers.Number)):
        return NotImplemented

    if np.any(self.full_value.values == 0):
        raise ZeroDivisionError('Cannot divide by zero')

    # First use __div__ to compute `self / other`
    # but first converting to the units of other
    inverse_result = self._rapply_operation(other, operator.truediv, units_must_match=False)
    return inverse_result

__setitem__(a, b)

setitem via slice is not allowed, since we currently do not give back a view to the DescriptorArray upon calling __getitem.

Source code in src/easyscience/variable/descriptor_array.py
745
746
747
748
749
750
751
752
753
754
def __setitem__(self, a, b: Union[numbers.Number, list, DescriptorNumber, DescriptorArray]):
    """__setitem via slice is not allowed, since we currently do not
    give back a view to the DescriptorArray upon calling
    __getitem__.
    """
    raise AttributeError(
        f'{self.__class__.__name__} cannot be edited via slicing. Edit the underlying scipp\
                array via the `full_value` property, or create a\
                new {self.__class__.__name__}.'
    )

__sub__(other)

Perform element-wise subtraction with another DescriptorArray, list, or number.

:param other: The object to subtract. Must be a DescriptorArray with compatible units, or a list with the same shape if the DescriptorArray is dimensionless. :return: A new DescriptorArray representing the result of the subtraction.

Source code in src/easyscience/variable/descriptor_array.py
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
def __sub__(
    self, other: Union[DescriptorArray, list, np.ndarray, numbers.Number]
) -> DescriptorArray:
    """Perform element-wise subtraction with another
    DescriptorArray, list, or number.

    :param other: The object to subtract. Must be a DescriptorArray
        with compatible units, or a list with the same shape if the
        DescriptorArray is dimensionless.
    :return: A new DescriptorArray representing the result of the
        subtraction.
    """
    if isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)):
        # Leverage __neg__ and __add__ for subtraction
        if isinstance(other, list):
            # Use numpy to negate all elements of the list
            value = (-np.array(other)).tolist()
        else:
            value = -other
        return self.__add__(value)
    else:
        return NotImplemented

__truediv__(other)

Perform element-wise division with another DescriptorNumber, DescriptorArray, list, or number.

:param other: The object to use as a denominator. Must be a DescriptorArray or DescriptorNumber with compatible units, or a list with the same shape if the DescriptorArray is dimensionless. :return: A new DescriptorArray representing the result of the addition.

Source code in src/easyscience/variable/descriptor_array.py
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
def __truediv__(
    self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]
) -> DescriptorArray:
    """Perform element-wise division with another DescriptorNumber,
    DescriptorArray, list, or number.

    :param other: The object to use as a denominator. Must be a
        DescriptorArray or DescriptorNumber with compatible units,
        or a list with the same shape if the DescriptorArray is
        dimensionless.
    :return: A new DescriptorArray representing the result of the
        addition.
    """
    if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)):
        return NotImplemented

    if isinstance(other, numbers.Number):
        original_other = other
    elif isinstance(other, list):
        original_other = np.array(other)
    elif isinstance(other, (DescriptorArray, DescriptorNumber)):
        original_other = other.value

    if np.any(original_other == 0):
        raise ZeroDivisionError('Cannot divide by zero')
    return self._apply_operation(other, operator.truediv, units_must_match=False)

as_dict(skip=None)

Dict representation of the current DescriptorArray.

The dict contains the value, unit and variances, in addition to the properties of DescriptorBase.

Source code in src/easyscience/variable/descriptor_array.py
368
369
370
371
372
373
374
375
376
377
378
379
def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
    """Dict representation of the current DescriptorArray.

    The dict contains the value, unit and variances, in addition to
    the properties of DescriptorBase.
    """
    raw_dict = super().as_dict(skip=skip)
    raw_dict['value'] = self._array.values
    raw_dict['unit'] = str(self._array.unit)
    raw_dict['variance'] = self._array.variances
    raw_dict['dimensions'] = self._array.dims
    return raw_dict

convert_unit(unit_str)

Convert the value from one unit system to another.

:param unit_str: New unit in string form

Source code in src/easyscience/variable/descriptor_array.py
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
def convert_unit(self, unit_str: str) -> None:
    """Convert the value from one unit system to another.

    :param unit_str: New unit in string form
    """
    if not isinstance(unit_str, str):
        raise TypeError(f'{unit_str=} must be a string representing a valid scipp unit')
    new_unit = sc.Unit(unit_str)

    # Save the current state for undo/redo
    old_array = self._array

    # Perform the unit conversion
    try:
        new_array = self._array.to(unit=new_unit)
    except Exception as e:
        raise UnitError(f'Failed to convert unit: {e}') from e

    # Define the setter function for the undo stack
    def set_array(obj, scalar):
        obj._array = scalar

    # Push to undo stack
    self._global_object.stack.push(
        PropertyStack(
            self, set_array, old_array, new_array, text=f'Convert unit to {unit_str}'
        )
    )

    # Update the array
    self._array = new_array

dimensions property writable

Get the dimensions used for the underlying scipp array.

:return: dimensions of self.

error property writable

The standard deviations, calculated as the square root of variances.

:return: A numpy array of standard deviations, or None if variances are not set.

from_scipp(name, full_value, **kwargs) classmethod

Create a DescriptorArray from a scipp array.

:param name: Name of the descriptor :param full_value: Value of the descriptor as a scipp variable :param kwargs: Additional parameters for the descriptor :return: DescriptorArray

Source code in src/easyscience/variable/descriptor_array.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
@classmethod
def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorArray:
    """Create a DescriptorArray from a scipp array.

    :param name: Name of the descriptor
    :param full_value: Value of the descriptor as a scipp variable
    :param kwargs: Additional parameters for the descriptor
    :return: DescriptorArray
    """
    if not isinstance(full_value, Variable):
        raise TypeError(f'{full_value=} must be a scipp array')
    return cls(
        name=name,
        value=full_value.values,
        unit=full_value.unit,
        variance=full_value.variances,
        dimensions=full_value.dims,
        **kwargs,
    )

full_value property writable

Get the value of self as a scipp array. This should be usable for most cases.

:return: Value of self with unit.

sum(dim=None)

Uses scipp to sum over the requested dims.

:param dim: The dim(s) in the scipp array to sum over. If None, will sum over all dims.

Source code in src/easyscience/variable/descriptor_array.py
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
def sum(
    self, dim: Optional[Union[str, list]] = None
) -> Union[DescriptorArray, DescriptorNumber]:
    """Uses scipp to sum over the requested dims.

    :param dim: The dim(s) in the scipp array to sum over. If `None`, will sum over all dims.
    """
    new_full_value = self.full_value.sum(dim=dim)

    # If fully reduced the result will be a DescriptorNumber,
    # otherwise a DescriptorArray
    if dim is None:
        constructor = DescriptorNumber.from_scipp
    else:
        constructor = DescriptorArray.from_scipp

    descriptor = constructor(name=self.name, full_value=new_full_value)
    descriptor.name = descriptor.unique_name
    return descriptor

trace(dimension1=None, dimension2=None)

Computes the trace over the descriptor array. The submatrix defined dimension1 and dimension2 must be square. For a rank k tensor, the trace will run over the firs two dimensions, resulting in a rank k-2 tensor.

:param dimension1, dimension2: First and second dimension to perform trace over. Must be in self.dimensions. If not defined, the trace will be taken over the first two dimensions.

Source code in src/easyscience/variable/descriptor_array.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
def trace(
    self, dimension1: Optional[str] = None, dimension2: Optional[str] = None
) -> Union[DescriptorArray, DescriptorNumber]:
    """Computes the trace over the descriptor array. The submatrix
    defined `dimension1` and `dimension2` must be square. For a rank
    `k` tensor, the trace will run over the firs two dimensions,
    resulting in a rank `k-2` tensor.

    :param dimension1, dimension2: First and second dimension to perform trace over. Must be in `self.dimensions`.
        If not defined, the trace will be taken over the first two dimensions.
    """
    if (dimension1 is not None and dimension2 is None) or (
        dimension1 is None and dimension2 is not None
    ):
        raise ValueError('Either both or none of `dimension1` and `dimension2` must be set.')

    if dimension1 is not None and dimension2 is not None:
        if dimension1 == dimension2:
            raise ValueError(f'`{dimension1=}` and `{dimension2=}` must be different.')

        axes = []
        for dim in (dimension1, dimension2):
            if dim not in self.dimensions:
                raise ValueError(f'Dimension {dim=} does not exist in `self.dimensions`.')
            index = self.dimensions.index(dim)
            axes.append(index)
        remaining_dimensions = [
            dim for dim in self.dimensions if dim not in (dimension1, dimension2)
        ]
    else:
        # Take the first two dimensions
        axes = (0, 1)
        # Pick out the remaining dims
        remaining_dimensions = self.dimensions[2:]

    trace_value = np.trace(self.value, axis1=axes[0], axis2=axes[1])
    trace_variance = (
        np.trace(self.variance, axis1=axes[0], axis2=axes[1])
        if self.variance is not None
        else None
    )
    # The trace reduces a rank k tensor to a k-2.
    if remaining_dimensions == []:
        # No remaining dimensions; the trace is a scalar
        trace = sc.scalar(value=trace_value, unit=self.unit, variance=trace_variance)
        constructor = DescriptorNumber.from_scipp
    else:
        # Else, the result is some array
        trace = sc.array(
            dims=remaining_dimensions,
            values=trace_value,
            unit=self.unit,
            variances=trace_variance,
        )
        constructor = DescriptorArray.from_scipp

    descriptor = constructor(name=self.name, full_value=trace)
    descriptor.name = descriptor.unique_name
    return descriptor

unit property writable

Get the unit.

:return: Unit as a string.

value property writable

Get the value without units. The Scipp array can be obtained from obj.full_value.

:return: Value of self without unit.

variance property writable

Get the variance as a Numpy ndarray.

:return: variance.

descriptor_base

DescriptorBase

Bases: SerializerComponent

This is the base of all variable descriptions for models. It contains all information to describe a single unique property of an object. This description includes a name and value as well as optionally a unit, description and url (for reference material). Also implemented is a callback so that the value can be read/set from a linked library object.

A Descriptor is typically something which describes part of a model and is non-fittable and generally changes the state of an object.

Source code in src/easyscience/variable/descriptor_base.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
class DescriptorBase(SerializerComponent, metaclass=abc.ABCMeta):
    """This is the base of all variable descriptions for models. It
    contains all information to describe a single unique property of an
    object. This description includes a name and value as well as
    optionally a unit, description and url (for reference material).
    Also implemented is a callback so that the value can be read/set
    from a linked library object.

    A `Descriptor` is typically something which describes part of a model and is non-fittable and generally changes the
    state of an object.
    """

    _global_object = global_object
    # Used by serializer
    _REDIRECT = {'parent': None}

    def __init__(
        self,
        name: str,
        unique_name: Optional[str] = None,
        description: Optional[str] = None,
        url: Optional[str] = None,
        display_name: Optional[str] = None,
        parent: Optional[Any] = None,
    ):
        """This is the base of variables for models. It contains all
        information to describe a single unique property of an object.
        This description includes a name, description and url (for
        reference material).

        A `Descriptor` is typically something which describes part of a model and is non-fittable and generally changes
        the state of an object.

        :param name: Name of this object
        :param description: A brief summary of what this object is
        :param url: Lookup url for documentation/information
        :param display_name: A pretty name for the object
        :param parent: The object which this descriptor is attached to

        .. note:: Undo/Redo functionality is implemented for the attributes `name` and `display name`.
        """

        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)
        self._unique_name = unique_name

        if not isinstance(name, str):
            raise TypeError('Name must be a string')
        self._name: str = name

        if display_name is not None and not isinstance(display_name, str):
            raise TypeError('Display name must be a string or None')
        self._display_name: str = display_name

        if description is not None and not isinstance(description, str):
            raise TypeError('Description must be a string or None')
        if description is None:
            description = ''
        self._description: str = description

        if url is not None and not isinstance(url, str):
            raise TypeError('url must be a string')
        if url is None:
            url = ''
        self._url: str = url

        # Let the collective know we've been assimilated
        self._parent = parent
        global_object.map.add_vertex(self, obj_type='created')
        # Make the connection between self and parent
        if parent is not None:
            global_object.map.add_edge(parent, self)

    @property
    def name(self) -> str:
        """Get the name of the object.

        :return: name of the object.
        """
        return self._name

    @name.setter
    @property_stack
    def name(self, new_name: str) -> None:
        """Set the name.

        :param new_name: name of the object.
        """
        if not isinstance(new_name, str):
            raise TypeError('Name must be a string')
        self._name = new_name

    @property
    def display_name(self) -> str:
        """Get a pretty display name.

        :return: The pretty display name.
        """
        display_name = self._display_name
        if display_name is None:
            display_name = self._name
        return display_name

    @display_name.setter
    @property_stack
    def display_name(self, name: str) -> None:
        """Set the pretty display name.

        :param name: Pretty display name of the object.
        """
        if name is not None and not isinstance(name, str):
            raise TypeError('Display name must be a string or None')
        self._display_name = name

    @property
    def description(self) -> str:
        """Get the description of the object.

        :return: description of the object.
        """
        return self._description

    @description.setter
    def description(self, description: str) -> None:
        """Set the description of the object.

        :param description: description of the object.
        """
        if description is not None and not isinstance(description, str):
            raise TypeError('Description must be a string or None')
        self._description = description

    @property
    def url(self) -> str:
        """Get the url of the object.

        :return: url of the object.
        """
        return self._url

    @url.setter
    def url(self, url: str) -> None:
        """Set the url of the object.

        :param url: url of the object.
        """
        if url is not None and not isinstance(url, str):
            raise TypeError('url must be a string')
        self._url = url

    @property
    def unique_name(self) -> str:
        """Get the unique name of this object.

        :return: Unique name of this object
        """
        return self._unique_name

    @unique_name.setter
    def unique_name(self, new_unique_name: str):
        """Set a new unique name for the object. The old name is still
        kept in the map.

        :param new_unique_name: New unique name for the object
        """
        if not isinstance(new_unique_name, str):
            raise TypeError('Unique name has to be a string.')
        self._unique_name = new_unique_name
        global_object.map.add_vertex(self)

    @property
    @abc.abstractmethod
    def value(self) -> Any:
        """Get the value of the object."""

    @value.setter
    @abc.abstractmethod
    def value(self, value: Any) -> None:
        """Set the value of the object."""

    @abc.abstractmethod
    def __repr__(self) -> str:
        """Return printable representation of the object."""

    def __copy__(self) -> DescriptorBase:
        """Return a copy of the object."""
        temp = self.as_dict(skip=['unique_name'])
        new_obj = self.__class__.from_dict(temp)
        return new_obj

__copy__()

Return a copy of the object.

Source code in src/easyscience/variable/descriptor_base.py
199
200
201
202
203
def __copy__(self) -> DescriptorBase:
    """Return a copy of the object."""
    temp = self.as_dict(skip=['unique_name'])
    new_obj = self.__class__.from_dict(temp)
    return new_obj

__init__(name, unique_name=None, description=None, url=None, display_name=None, parent=None)

This is the base of variables for models. It contains all information to describe a single unique property of an object. This description includes a name, description and url (for reference material).

A Descriptor is typically something which describes part of a model and is non-fittable and generally changes the state of an object.

:param name: Name of this object :param description: A brief summary of what this object is :param url: Lookup url for documentation/information :param display_name: A pretty name for the object :param parent: The object which this descriptor is attached to

.. note:: Undo/Redo functionality is implemented for the attributes name and display name.

Source code in src/easyscience/variable/descriptor_base.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def __init__(
    self,
    name: str,
    unique_name: Optional[str] = None,
    description: Optional[str] = None,
    url: Optional[str] = None,
    display_name: Optional[str] = None,
    parent: Optional[Any] = None,
):
    """This is the base of variables for models. It contains all
    information to describe a single unique property of an object.
    This description includes a name, description and url (for
    reference material).

    A `Descriptor` is typically something which describes part of a model and is non-fittable and generally changes
    the state of an object.

    :param name: Name of this object
    :param description: A brief summary of what this object is
    :param url: Lookup url for documentation/information
    :param display_name: A pretty name for the object
    :param parent: The object which this descriptor is attached to

    .. note:: Undo/Redo functionality is implemented for the attributes `name` and `display name`.
    """

    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)
    self._unique_name = unique_name

    if not isinstance(name, str):
        raise TypeError('Name must be a string')
    self._name: str = name

    if display_name is not None and not isinstance(display_name, str):
        raise TypeError('Display name must be a string or None')
    self._display_name: str = display_name

    if description is not None and not isinstance(description, str):
        raise TypeError('Description must be a string or None')
    if description is None:
        description = ''
    self._description: str = description

    if url is not None and not isinstance(url, str):
        raise TypeError('url must be a string')
    if url is None:
        url = ''
    self._url: str = url

    # Let the collective know we've been assimilated
    self._parent = parent
    global_object.map.add_vertex(self, obj_type='created')
    # Make the connection between self and parent
    if parent is not None:
        global_object.map.add_edge(parent, self)

__repr__() abstractmethod

Return printable representation of the object.

Source code in src/easyscience/variable/descriptor_base.py
195
196
197
@abc.abstractmethod
def __repr__(self) -> str:
    """Return printable representation of the object."""

description property writable

Get the description of the object.

:return: description of the object.

display_name property writable

Get a pretty display name.

:return: The pretty display name.

name property writable

Get the name of the object.

:return: name of the object.

unique_name property writable

Get the unique name of this object.

:return: Unique name of this object

url property writable

Get the url of the object.

:return: url of the object.

value abstractmethod property writable

Get the value of the object.

descriptor_bool

DescriptorBool

Bases: DescriptorBase

A Descriptor for boolean values.

Source code in src/easyscience/variable/descriptor_bool.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class DescriptorBool(DescriptorBase):
    """A `Descriptor` for boolean values."""

    def __init__(
        self,
        name: str,
        value: bool,
        unique_name: Optional[str] = None,
        description: Optional[str] = None,
        url: Optional[str] = None,
        display_name: Optional[str] = None,
        parent: Optional[Any] = None,
    ):
        if not isinstance(value, bool):
            raise ValueError(f'{value=} must be type bool')
        super().__init__(
            name=name,
            unique_name=unique_name,
            description=description,
            url=url,
            display_name=display_name,
            parent=parent,
        )
        if not isinstance(value, bool):
            raise TypeError(f'{value=} must be type bool')
        self._bool_value = value

    @property
    def value(self) -> bool:
        """Get the value of self.

        :return: Value of self
        """
        return self._bool_value

    @value.setter
    @property_stack
    def value(self, value: bool) -> None:
        """Set the value of self.

        :param value: New value of self
        :return: None
        """
        if not isinstance(value, bool):
            raise TypeError(f'{value=} must be type bool')
        self._bool_value = value

    def __repr__(self) -> str:
        """Return printable representation."""
        class_name = self.__class__.__name__
        obj_name = self._name
        obj_value = self._bool_value
        return f"<{class_name} '{obj_name}': {obj_value}>"

    # To get return type right
    def __copy__(self) -> DescriptorBool:
        return super().__copy__()

__repr__()

Return printable representation.

Source code in src/easyscience/variable/descriptor_bool.py
61
62
63
64
65
66
def __repr__(self) -> str:
    """Return printable representation."""
    class_name = self.__class__.__name__
    obj_name = self._name
    obj_value = self._bool_value
    return f"<{class_name} '{obj_name}': {obj_value}>"

value property writable

Get the value of self.

:return: Value of self

descriptor_number

DescriptorNumber

Bases: DescriptorBase

A Descriptor for Number values with units.

The internal representation is a scipp scalar.

Source code in src/easyscience/variable/descriptor_number.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
class DescriptorNumber(DescriptorBase):
    """A `Descriptor` for Number values with units.

    The internal representation is a scipp scalar.
    """

    def __init__(
        self,
        name: str,
        value: numbers.Number,
        unit: Optional[Union[str, sc.Unit]] = '',
        variance: Optional[numbers.Number] = None,
        unique_name: Optional[str] = None,
        description: Optional[str] = None,
        url: Optional[str] = None,
        display_name: Optional[str] = None,
        parent: Optional[Any] = None,
        **kwargs: Any,  # Additional keyword arguments (used for (de)serialization)
    ):
        """Constructor for the DescriptorNumber class.

        param name: Name of the descriptor
        param value: Value of the descriptor
        param unit: Unit of the descriptor
        param variance: Variance of the descriptor
        param description: Description of the descriptor
        param url: URL of the descriptor
        param display_name: Display name of the descriptor
        param parent: Parent of the descriptor
        .. note:: Undo/Redo functionality is implemented for the attributes `variance`, `error`, `unit` and `value`.
        """
        self._observers: List[DescriptorNumber] = []

        # Extract serializer_id if provided during deserialization
        if '__serializer_id' in kwargs:
            self.__serializer_id = kwargs.pop('__serializer_id')

        if not isinstance(value, numbers.Number) or isinstance(value, bool):
            raise TypeError(f'{value=} must be a number')
        if variance is not None:
            if not isinstance(variance, numbers.Number) or isinstance(variance, bool):
                raise TypeError(f'{variance=} must be a number or None')
            if variance < 0:
                raise ValueError(f'{variance=} must be positive')
            variance = float(variance)
        if not isinstance(unit, sc.Unit) and not isinstance(unit, str):
            raise TypeError(
                f'{unit=} must be a scipp unit or a string representing a valid scipp unit'
            )
        try:
            self._scalar = sc.scalar(float(value), unit=unit, variance=variance)
        except Exception as message:
            raise UnitError(message)
        super().__init__(
            name=name,
            unique_name=unique_name,
            description=description,
            url=url,
            display_name=display_name,
            parent=parent,
        )

        # Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency.
        if self.unit is not None:
            self._convert_unit(self._base_unit())

    @classmethod
    def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorNumber:
        """Create a DescriptorNumber from a scipp constant.

        :param name: Name of the descriptor
        :param value: Value of the descriptor as a scipp scalar
        :param kwargs: Additional parameters for the descriptor
        :return: DescriptorNumber
        """
        if not isinstance(full_value, Variable):
            raise TypeError(f'{full_value=} must be a scipp scalar')
        if len(full_value.dims) != 0:
            raise TypeError(f'{full_value=} must be a scipp scalar')
        return cls(
            name=name,
            value=full_value.value,
            unit=full_value.unit,
            variance=full_value.variance,
            **kwargs,
        )

    def _attach_observer(self, observer: DescriptorNumber) -> None:
        """Attach an observer to the descriptor."""
        self._observers.append(observer)
        if not hasattr(self, '_DescriptorNumber__serializer_id'):
            self.__serializer_id = str(uuid.uuid4())

    def _detach_observer(self, observer: DescriptorNumber) -> None:
        """Detach an observer from the descriptor."""
        self._observers.remove(observer)
        if not self._observers:
            del self.__serializer_id

    def _notify_observers(self) -> None:
        """Notify all observers of a change."""
        for observer in self._observers:
            observer._update()

    def _validate_dependencies(self, origin=None) -> None:
        """Ping all observers to check if any cyclic dependencies have
        been introduced.

        :param origin: Unique_name of the origin of this validation
            check. Used to avoid cyclic depenencies.
        """
        if origin == self.unique_name:
            raise RuntimeError(
                '\n Cyclic dependency detected!\n'
                + f'An update of {self.unique_name} leads to it updating itself.\n'
                + 'Please check your dependencies.'
            )
        if origin is None:
            origin = self.unique_name
        for observer in self._observers:
            observer._validate_dependencies(origin=origin)

    @property
    def full_value(self) -> Variable:
        """Get the value of self as a scipp scalar. This is should be
        usable for most cases.

        :return: Value of self with unit.
        """
        return self._scalar

    @full_value.setter
    def full_value(self, full_value: Variable) -> None:
        raise AttributeError(
            f'Full_value is read-only. Change the value and variance seperately. Or create a new {self.__class__.__name__}.'
        )

    @property
    def value(self) -> numbers.Number:
        """Get the value. This should be usable for most cases. The full
        value can be obtained from `obj.full_value`.

        :return: Value of self with unit.
        """
        return self._scalar.value

    @value.setter
    @notify_observers
    @property_stack
    def value(self, value: numbers.Number) -> None:
        """Set the value of self. This should be usable for most cases.
        The full value can be obtained from `obj.full_value`.

        :param value: New value of self
        """
        if not isinstance(value, numbers.Number) or isinstance(value, bool):
            raise TypeError(f'{value=} must be a number')
        self._scalar.value = float(value)

    @property
    def unit(self) -> str:
        """Get the unit.

        :return: Unit as a string.
        """
        return str(self._scalar.unit)

    @unit.setter
    def unit(self, unit_str: str) -> None:
        raise AttributeError(
            (
                f'Unit is read-only. Use convert_unit to change the unit between allowed types '
                f'or create a new {self.__class__.__name__} with the desired unit.'
            )
        )  # noqa: E501

    @property
    def variance(self) -> float:
        """Get the variance.

        :return: variance.
        """
        return self._scalar.variance

    @variance.setter
    @notify_observers
    @property_stack
    def variance(self, variance_float: float) -> None:
        """Set the variance.

        :param variance_float: Variance as a float
        """
        if variance_float is not None:
            if not isinstance(variance_float, numbers.Number):
                raise TypeError(f'{variance_float=} must be a number or None')
            if variance_float < 0:
                raise ValueError(f'{variance_float=} must be positive')
            variance_float = float(variance_float)
        self._scalar.variance = variance_float

    @property
    def error(self) -> float:
        """The standard deviation for the parameter.

        :return: Error associated with parameter
        """
        if self._scalar.variance is None:
            return None
        return float(np.sqrt(self._scalar.variance))

    @error.setter
    @notify_observers
    @property_stack
    def error(self, value: float) -> None:
        """Set the standard deviation for the parameter.

        :param value: New error value
        """
        if value is not None:
            if not isinstance(value, numbers.Number):
                raise TypeError(f'{value=} must be a number or None')
            if value < 0:
                raise ValueError(f'{value=} must be positive')
            value = float(value)
            self._scalar.variance = value**2
        else:
            self._scalar.variance = None

    # When we convert units internally, we dont want to notify observers as this can cause infinite recursion.
    # Therefore the convert_unit method is split into two methods, a private internal method and a public method.
    def _convert_unit(self, unit_str: str) -> None:
        """Convert the value from one unit system to another.

        :param unit_str: New unit in string form
        """
        if not isinstance(unit_str, str):
            raise TypeError(f'{unit_str=} must be a string representing a valid scipp unit')
        new_unit = sc.Unit(unit_str)

        # Save the current state for undo/redo
        old_scalar = self._scalar

        # Perform the unit conversion
        try:
            new_scalar = self._scalar.to(unit=new_unit)
        except Exception as e:
            raise UnitError(f'Failed to convert unit: {e}') from e

        # Define the setter function for the undo stack
        def set_scalar(obj, scalar):
            obj._scalar = scalar

        # Push to undo stack
        self._global_object.stack.push(
            PropertyStack(
                self, set_scalar, old_scalar, new_scalar, text=f'Convert unit to {unit_str}'
            )
        )

        # Update the scalar
        self._scalar = new_scalar

    # When the user calls convert_unit, we want to notify observers of the change to propagate the change.
    @notify_observers
    def convert_unit(self, unit_str: str) -> None:
        """Convert the value from one unit system to another.

        :param unit_str: New unit in string form
        """
        self._convert_unit(unit_str)

    # Just to get return type right
    def __copy__(self) -> DescriptorNumber:
        return super().__copy__()

    def __repr__(self) -> str:
        """Return printable representation."""
        string = '<'
        string += self.__class__.__name__ + ' '
        string += f"'{self._name}': "
        if np.abs(self._scalar.value) > 1e4 or (
            np.abs(self._scalar.value) < 1e-4 and self._scalar.value != 0
        ):
            # Use scientific notation for large or small values
            string += f'{self._scalar.value:.3e}'
            if self.variance:
                string += f' \u00b1 {self.error:.3e}'
        else:
            string += f'{self._scalar.value:.4f}'
            if self.variance:
                string += f' \u00b1 {self.error:.4f}'
        obj_unit = self._scalar.unit
        if obj_unit == 'dimensionless':
            obj_unit = ''
        else:
            obj_unit = f' {obj_unit}'
        string += obj_unit
        string += '>'
        return string
        # return f"<{class_name} '{obj_name}': {obj_value:0.04f}{obj_unit}>"

    def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
        raw_dict = super().as_dict(skip=skip)
        raw_dict['value'] = self._scalar.value
        raw_dict['unit'] = str(self._scalar.unit)
        raw_dict['variance'] = self._scalar.variance
        if hasattr(self, '_DescriptorNumber__serializer_id'):
            raw_dict['__serializer_id'] = self.__serializer_id
        return raw_dict

    def __add__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
        if isinstance(other, numbers.Number):
            if self.unit != 'dimensionless':
                raise UnitError('Numbers can only be added to dimensionless values')
            new_value = self.full_value + other
        elif type(other) is DescriptorNumber:
            original_unit = other.unit
            try:
                other._convert_unit(self.unit)
            except UnitError:
                raise UnitError(
                    f'Values with units {self.unit} and {other.unit} cannot be added'
                ) from None
            new_value = self.full_value + other.full_value
            other._convert_unit(original_unit)
        else:
            return NotImplemented
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __radd__(self, other: numbers.Number) -> DescriptorNumber:
        if isinstance(other, numbers.Number):
            if self.unit != 'dimensionless':
                raise UnitError('Numbers can only be added to dimensionless values')
            new_value = other + self.full_value
        else:
            return NotImplemented
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __sub__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
        if isinstance(other, numbers.Number):
            if self.unit != 'dimensionless':
                raise UnitError('Numbers can only be subtracted from dimensionless values')
            new_value = self.full_value - other
        elif type(other) is DescriptorNumber:
            original_unit = other.unit
            try:
                other._convert_unit(self.unit)
            except UnitError:
                raise UnitError(
                    f'Values with units {self.unit} and {other.unit} cannot be subtracted'
                ) from None
            new_value = self.full_value - other.full_value
            other._convert_unit(original_unit)
        else:
            return NotImplemented
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __rsub__(self, other: numbers.Number) -> DescriptorNumber:
        if isinstance(other, numbers.Number):
            if self.unit != 'dimensionless':
                raise UnitError('Numbers can only be subtracted from dimensionless values')
            new_value = other - self.full_value
        else:
            return NotImplemented
        descriptor = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor.name = descriptor.unique_name
        return descriptor

    def __mul__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
        if isinstance(other, numbers.Number):
            new_value = self.full_value * other
        elif type(other) is DescriptorNumber:
            new_value = self.full_value * other.full_value
        else:
            return NotImplemented
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number._convert_unit(descriptor_number._base_unit())
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __rmul__(self, other: numbers.Number) -> DescriptorNumber:
        if isinstance(other, numbers.Number):
            new_value = other * self.full_value
        else:
            return NotImplemented
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __truediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
        if isinstance(other, numbers.Number):
            if other == 0:
                raise ZeroDivisionError('Cannot divide by zero')
            new_value = self.full_value / other
        elif type(other) is DescriptorNumber:
            if other.value == 0:
                raise ZeroDivisionError('Cannot divide by zero')
            new_value = self.full_value / other.full_value
        else:
            return NotImplemented
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number._convert_unit(descriptor_number._base_unit())
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __rtruediv__(self, other: numbers.Number) -> DescriptorNumber:
        if isinstance(other, numbers.Number):
            if self.value == 0:
                raise ZeroDivisionError('Cannot divide by zero')
            new_value = other / self.full_value
        else:
            return NotImplemented
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
        if isinstance(other, numbers.Number):
            exponent = other
        elif type(other) is DescriptorNumber:
            if other.unit != 'dimensionless':
                raise UnitError('Exponents must be dimensionless')
            if other.variance is not None:
                raise ValueError('Exponents must not have variance')
            exponent = other.value
        else:
            return NotImplemented
        try:
            new_value = self.full_value**exponent
        except Exception as message:
            raise message from None
        if np.isnan(new_value.value):
            raise ValueError('The result of the exponentiation is not a number')
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __rpow__(self, other: numbers.Number) -> numbers.Number:
        if isinstance(other, numbers.Number):
            if self.unit != 'dimensionless':
                raise UnitError('Exponents must be dimensionless')
            if self.variance is not None:
                raise ValueError('Exponents must not have variance')
            new_value = other**self.value
        else:
            return NotImplemented
        return new_value

    def __neg__(self) -> DescriptorNumber:
        new_value = -self.full_value
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def __abs__(self) -> DescriptorNumber:
        new_value = abs(self.full_value)
        descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
        descriptor_number.name = descriptor_number.unique_name
        return descriptor_number

    def _base_unit(self) -> str:
        """Extract the base unit from the unit string by removing
        numeric components and scientific notation.
        """
        string = str(self._scalar.unit)
        for i, letter in enumerate(string):
            if letter == 'e':
                if string[i : i + 2] not in ['e+', 'e-']:
                    return string[i:]
            elif letter not in ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '+', '-']:
                return string[i:]
        return ''

__init__(name, value, unit='', variance=None, unique_name=None, description=None, url=None, display_name=None, parent=None, **kwargs)

Constructor for the DescriptorNumber class.

param name: Name of the descriptor param value: Value of the descriptor param unit: Unit of the descriptor param variance: Variance of the descriptor param description: Description of the descriptor param url: URL of the descriptor param display_name: Display name of the descriptor param parent: Parent of the descriptor .. note:: Undo/Redo functionality is implemented for the attributes variance, error, unit and value.

Source code in src/easyscience/variable/descriptor_number.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def __init__(
    self,
    name: str,
    value: numbers.Number,
    unit: Optional[Union[str, sc.Unit]] = '',
    variance: Optional[numbers.Number] = None,
    unique_name: Optional[str] = None,
    description: Optional[str] = None,
    url: Optional[str] = None,
    display_name: Optional[str] = None,
    parent: Optional[Any] = None,
    **kwargs: Any,  # Additional keyword arguments (used for (de)serialization)
):
    """Constructor for the DescriptorNumber class.

    param name: Name of the descriptor
    param value: Value of the descriptor
    param unit: Unit of the descriptor
    param variance: Variance of the descriptor
    param description: Description of the descriptor
    param url: URL of the descriptor
    param display_name: Display name of the descriptor
    param parent: Parent of the descriptor
    .. note:: Undo/Redo functionality is implemented for the attributes `variance`, `error`, `unit` and `value`.
    """
    self._observers: List[DescriptorNumber] = []

    # Extract serializer_id if provided during deserialization
    if '__serializer_id' in kwargs:
        self.__serializer_id = kwargs.pop('__serializer_id')

    if not isinstance(value, numbers.Number) or isinstance(value, bool):
        raise TypeError(f'{value=} must be a number')
    if variance is not None:
        if not isinstance(variance, numbers.Number) or isinstance(variance, bool):
            raise TypeError(f'{variance=} must be a number or None')
        if variance < 0:
            raise ValueError(f'{variance=} must be positive')
        variance = float(variance)
    if not isinstance(unit, sc.Unit) and not isinstance(unit, str):
        raise TypeError(
            f'{unit=} must be a scipp unit or a string representing a valid scipp unit'
        )
    try:
        self._scalar = sc.scalar(float(value), unit=unit, variance=variance)
    except Exception as message:
        raise UnitError(message)
    super().__init__(
        name=name,
        unique_name=unique_name,
        description=description,
        url=url,
        display_name=display_name,
        parent=parent,
    )

    # Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency.
    if self.unit is not None:
        self._convert_unit(self._base_unit())

__repr__()

Return printable representation.

Source code in src/easyscience/variable/descriptor_number.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
def __repr__(self) -> str:
    """Return printable representation."""
    string = '<'
    string += self.__class__.__name__ + ' '
    string += f"'{self._name}': "
    if np.abs(self._scalar.value) > 1e4 or (
        np.abs(self._scalar.value) < 1e-4 and self._scalar.value != 0
    ):
        # Use scientific notation for large or small values
        string += f'{self._scalar.value:.3e}'
        if self.variance:
            string += f' \u00b1 {self.error:.3e}'
    else:
        string += f'{self._scalar.value:.4f}'
        if self.variance:
            string += f' \u00b1 {self.error:.4f}'
    obj_unit = self._scalar.unit
    if obj_unit == 'dimensionless':
        obj_unit = ''
    else:
        obj_unit = f' {obj_unit}'
    string += obj_unit
    string += '>'
    return string

convert_unit(unit_str)

Convert the value from one unit system to another.

:param unit_str: New unit in string form

Source code in src/easyscience/variable/descriptor_number.py
305
306
307
308
309
310
311
@notify_observers
def convert_unit(self, unit_str: str) -> None:
    """Convert the value from one unit system to another.

    :param unit_str: New unit in string form
    """
    self._convert_unit(unit_str)

error property writable

The standard deviation for the parameter.

:return: Error associated with parameter

from_scipp(name, full_value, **kwargs) classmethod

Create a DescriptorNumber from a scipp constant.

:param name: Name of the descriptor :param value: Value of the descriptor as a scipp scalar :param kwargs: Additional parameters for the descriptor :return: DescriptorNumber

Source code in src/easyscience/variable/descriptor_number.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
@classmethod
def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorNumber:
    """Create a DescriptorNumber from a scipp constant.

    :param name: Name of the descriptor
    :param value: Value of the descriptor as a scipp scalar
    :param kwargs: Additional parameters for the descriptor
    :return: DescriptorNumber
    """
    if not isinstance(full_value, Variable):
        raise TypeError(f'{full_value=} must be a scipp scalar')
    if len(full_value.dims) != 0:
        raise TypeError(f'{full_value=} must be a scipp scalar')
    return cls(
        name=name,
        value=full_value.value,
        unit=full_value.unit,
        variance=full_value.variance,
        **kwargs,
    )

full_value property writable

Get the value of self as a scipp scalar. This is should be usable for most cases.

:return: Value of self with unit.

unit property writable

Get the unit.

:return: Unit as a string.

value property writable

Get the value. This should be usable for most cases. The full value can be obtained from obj.full_value.

:return: Value of self with unit.

variance property writable

Get the variance.

:return: variance.

notify_observers(func)

Decorator to notify observers of a change in the descriptor.

:param func: Function to be decorated :return: Decorated function

Source code in src/easyscience/variable/descriptor_number.py
27
28
29
30
31
32
33
34
35
36
37
38
39
def notify_observers(func):
    """Decorator to notify observers of a change in the descriptor.

    :param func: Function to be decorated
    :return: Decorated function
    """

    def wrapper(self, *args, **kwargs):
        result = func(self, *args, **kwargs)
        self._notify_observers()
        return result

    return wrapper

descriptor_str

DescriptorStr

Bases: DescriptorBase

A Descriptor for string values.

Source code in src/easyscience/variable/descriptor_str.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class DescriptorStr(DescriptorBase):
    """A `Descriptor` for string values."""

    def __init__(
        self,
        name: str,
        value: str,
        unique_name: Optional[str] = None,
        description: Optional[str] = None,
        url: Optional[str] = None,
        display_name: Optional[str] = None,
        parent: Optional[Any] = None,
    ):
        super().__init__(
            name=name,
            unique_name=unique_name,
            description=description,
            url=url,
            display_name=display_name,
            parent=parent,
        )
        if not isinstance(value, str):
            raise ValueError(f'{value=} must be type str')
        self._string = value

    @property
    def value(self) -> str:
        """Get the value of self.

        :return: Value of self with unit.
        """
        return self._string

    @value.setter
    @property_stack
    def value(self, value: str) -> None:
        """Set the value of self.

        :param value: New value of self
        :return: None
        """
        if not isinstance(value, str):
            raise ValueError(f'{value=} must be type str')
        self._string = value

    def __repr__(self) -> str:
        """Return printable representation."""
        class_name = self.__class__.__name__
        obj_name = self._name
        obj_value = self._string
        return f"<{class_name} '{obj_name}': {obj_value}>"

    # To get return type right
    def __copy__(self) -> DescriptorStr:
        return super().__copy__()

__repr__()

Return printable representation.

Source code in src/easyscience/variable/descriptor_str.py
59
60
61
62
63
64
def __repr__(self) -> str:
    """Return printable representation."""
    class_name = self.__class__.__name__
    obj_name = self._name
    obj_value = self._string
    return f"<{class_name} '{obj_name}': {obj_value}>"

value property writable

Get the value of self.

:return: Value of self with unit.

parameter

Parameter

Bases: DescriptorNumber

A Parameter is a DescriptorNumber which can be used in fitting.

It has additional fields to facilitate this.

Source code in src/easyscience/variable/parameter.py
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
class Parameter(DescriptorNumber):
    """A Parameter is a DescriptorNumber which can be used in fitting.

    It has additional fields to facilitate this.
    """

    # Used by serializer
    # We copy the parent's _REDIRECT and modify it to avoid altering the parent's class dict
    _REDIRECT = DescriptorNumber._REDIRECT.copy()
    _REDIRECT['callback'] = None

    def __init__(
        self,
        name: str,
        value: numbers.Number,
        unit: Optional[Union[str, sc.Unit]] = '',
        variance: Optional[numbers.Number] = 0.0,
        min: Optional[numbers.Number] = -np.inf,
        max: Optional[numbers.Number] = np.inf,
        fixed: Optional[bool] = False,
        unique_name: Optional[str] = None,
        description: Optional[str] = None,
        url: Optional[str] = None,
        display_name: Optional[str] = None,
        callback: property = property(),
        parent: Optional[Any] = None,
        **kwargs: Any,  # Additional keyword arguments (used for (de)serialization)
    ):
        """This class is an extension of a `DescriptorNumber`. Where the
        descriptor was for static objects, a `Parameter` is for dynamic
        objects. A parameter has the ability to be used in fitting and
        has additional fields to facilitate this.

        :param name: Name of this object
        :param value: Value of this object
        :param unit: This object can have a physical unit associated with it
        :param variance: The variance of the value
        :param min: The minimum value for fitting
        :param max: The maximum value for fitting
        :param fixed: If the parameter is free to vary during fitting
        :param description: A brief summary of what this object is
        :param url: Lookup url for documentation/information
        :param display_name: The name of the object as it should be displayed
        :param parent: The object which is the parent to this one

        Note:
            Undo/Redo functionality is implemented for the attributes `value`, `variance`, `error`, `min`, `max`, `bounds`, `fixed`, `unit`
        """  # noqa: E501
        # Extract and ignore serialization-specific fields from kwargs
        kwargs.pop('_dependency_string', None)
        kwargs.pop('_dependency_map_serializer_ids', None)
        kwargs.pop('_independent', None)

        if not isinstance(min, numbers.Number):
            raise TypeError('`min` must be a number')
        if not isinstance(max, numbers.Number):
            raise TypeError('`max` must be a number')
        if not isinstance(value, numbers.Number):
            raise TypeError('`value` must be a number')
        if value < min:
            raise ValueError(f'{value=} can not be less than {min=}')
        if value > max:
            raise ValueError(f'{value=} can not be greater than {max=}')
        if np.isclose(min, max, rtol=1e-9, atol=0.0):
            raise ValueError(
                'The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.'
            )
        if not isinstance(fixed, bool):
            raise TypeError('`fixed` must be either True or False')
        self._independent = True
        self._fixed = fixed  # For fitting, but must be initialized before super().__init__
        self._min = sc.scalar(float(min), unit=unit)
        self._max = sc.scalar(float(max), unit=unit)

        super().__init__(
            name=name,
            value=value,
            unit=unit,
            variance=variance,
            unique_name=unique_name,
            description=description,
            url=url,
            display_name=display_name,
            parent=parent,
            **kwargs,  # Additional keyword arguments (used for (de)serialization)
        )

        self._callback = callback  # Callback is used by interface to link to model
        if self._callback.fdel is not None:
            weakref.finalize(self, self._callback.fdel)

        # Create additional fitting elements
        self._initial_scalar = copy.deepcopy(self._scalar)

    @classmethod
    def from_dependency(
        cls,
        name: str,
        dependency_expression: str,
        dependency_map: Optional[dict] = None,
        desired_unit: str | sc.Unit | None = None,
        **kwargs,
    ) -> Parameter:  # noqa: E501
        """Create a dependent Parameter directly from a dependency
        expression.

        :param name: The name of the parameter
        :param dependency_expression: The dependency expression to
            evaluate. This should be a string which can be evaluated by
            the ASTEval interpreter.
        :param dependency_map: A dictionary of dependency expression
            symbol name and dependency object pairs. This is inserted
            into the asteval interpreter to resolve dependencies.
        :param desired_unit: The desired unit of the dependent
            parameter.
        :param kwargs: Additional keyword arguments to pass to the
            Parameter constructor.
        :return: A new dependent Parameter object.
        """  # noqa: E501
        # Set default values for required parameters for the constructor, they get overwritten by the dependency anyways
        default_kwargs = {'value': 0.0, 'variance': 0.0, 'min': -np.inf, 'max': np.inf}
        # Update with user-provided kwargs, to avoid errors.
        default_kwargs.update(kwargs)
        parameter = cls(name=name, **default_kwargs)
        parameter.make_dependent_on(
            dependency_expression=dependency_expression,
            dependency_map=dependency_map,
            desired_unit=desired_unit,
        )
        return parameter

    def _update(self) -> None:
        """Update the parameter.

        This is called by the DescriptorNumbers/Parameters who have this
        Parameter as a dependency.
        """
        if not self._independent:
            # Update the value of the parameter using the dependency interpreter
            temporary_parameter = self._dependency_interpreter(self._clean_dependency_string)
            self._scalar.value = temporary_parameter.value
            self._scalar.unit = temporary_parameter.unit
            self._scalar.variance = temporary_parameter.variance
            self._min.value = (
                temporary_parameter.min
                if isinstance(temporary_parameter, Parameter)
                else temporary_parameter.value
            )  # noqa: E501
            self._max.value = (
                temporary_parameter.max
                if isinstance(temporary_parameter, Parameter)
                else temporary_parameter.value
            )  # noqa: E501
            self._min.unit = temporary_parameter.unit
            self._max.unit = temporary_parameter.unit

            if self._desired_unit is not None:
                self._convert_unit(self._desired_unit)

            self._notify_observers()
        else:
            warnings.warn('This parameter is not dependent. It cannot be updated.')

    def make_dependent_on(
        self,
        dependency_expression: str,
        dependency_map: Optional[dict] = None,
        desired_unit: str | sc.Unit | None = None,
    ) -> None:
        """Make this parameter dependent on another parameter. This will
        overwrite the current value, unit, variance, min and max.

        How to use the dependency map:
        If a parameter c has a dependency expression of 'a + b', where a and b are parameters belonging to the model class,
        then the dependency map needs to have the form {'a': model.a, 'b': model.b}, where model is the model class.
        I.e. the values are the actual objects, whereas the keys are how they are represented in the dependency expression.

        The dependency map is not needed if the dependency expression uses the unique names of the parameters.
        Unique names in dependency expressions are defined by quotes, e.g. 'Parameter_0' or "Parameter_0" depending on
        the quotes used for the expression.

        :param dependency_expression:
            The dependency expression to evaluate. This should be a string which
            can be evaluated by a python interpreter.

        :param dependency_map:
            A dictionary of dependency expression symbol name and dependency object pairs.
            This is inserted into the asteval interpreter to resolve dependencies.

        :param desired_unit:
            The desired unit of the dependent parameter. If None, the default unit of the dependency expression result is used.
        """  # noqa: E501
        if not isinstance(dependency_expression, str):
            raise TypeError(
                '`dependency_expression` must be a string representing a valid dependency expression.'
            )
        if not (isinstance(dependency_map, dict) or dependency_map is None):
            raise TypeError(
                '`dependency_map` must be a dictionary of dependencies and their'
                'corresponding names in the dependecy expression.'
            )  # noqa: E501
        if isinstance(dependency_map, dict):
            for key, value in dependency_map.items():
                if not isinstance(key, str):
                    raise TypeError(
                        '`dependency_map` keys must be strings representing the names of'
                        'the dependencies in the dependency expression.'
                    )  # noqa: E501
                if not isinstance(value, DescriptorNumber):
                    raise TypeError(
                        f'`dependency_map` values must be DescriptorNumbers or Parameters. Got {type(value)} for {key}.'
                    )  # noqa: E501

        # If we're overwriting the dependency, store the old attributes
        # in case we need to revert back to the old dependency
        self._previous_independent = self._independent
        if not self._independent:
            self._previous_dependency = {
                '_dependency_string': self._dependency_string,
                '_dependency_map': self._dependency_map,
                '_dependency_interpreter': self._dependency_interpreter,
                '_clean_dependency_string': self._clean_dependency_string,
                '_desired_unit': self._desired_unit,
            }
            for dependency in self._dependency_map.values():
                dependency._detach_observer(self)

        self._independent = False
        self._dependency_string = dependency_expression
        self._dependency_map = dependency_map if dependency_map is not None else {}
        if desired_unit is not None and not (
            isinstance(desired_unit, str) or isinstance(desired_unit, sc.Unit)
        ):
            raise TypeError('`desired_unit` must be a string representing a valid unit.')
        self._desired_unit = desired_unit
        # List of allowed python constructs for the asteval interpreter
        asteval_config = {
            'import': False,
            'importfrom': False,
            'assert': False,
            'augassign': False,
            'delete': False,
            'if': True,
            'ifexp': True,
            'for': False,
            'formattedvalue': False,
            'functiondef': False,
            'print': False,
            'raise': False,
            'listcomp': False,
            'dictcomp': False,
            'setcomp': False,
            'try': False,
            'while': False,
            'with': False,
        }
        self._dependency_interpreter = Interpreter(config=asteval_config)

        # Process the dependency expression for unique names
        try:
            self._process_dependency_unique_names(self._dependency_string)
        except ValueError as error:
            self._revert_dependency(skip_detach=True)
            raise error

        for key, value in self._dependency_map.items():
            self._dependency_interpreter.symtable[key] = value
            self._dependency_interpreter.readonly_symbols.add(
                key
            )  # Dont allow overwriting of the dependencies in the dependency expression  # noqa: E501
            value._attach_observer(self)
        # Check the dependency expression for errors
        try:
            dependency_result = self._dependency_interpreter.eval(
                self._clean_dependency_string, raise_errors=True
            )
        except NameError as message:
            self._revert_dependency()
            raise NameError(
                '\nUnknown name encountered in dependecy expression:'
                + '\n'
                + '\n'.join(str(message).split('\n')[1:])
                + '\nPlease check your expression or add the name to the `dependency_map`'
            ) from None
        except Exception as message:
            self._revert_dependency()
            raise SyntaxError(
                '\nError encountered in dependecy expression:'
                + '\n'
                + '\n'.join(str(message).split('\n')[1:])
                + '\nPlease check your expression'
            ) from None
        if not isinstance(dependency_result, DescriptorNumber):
            error_string = self._dependency_string
            self._revert_dependency()
            raise TypeError(
                f'The dependency expression: "{error_string}" returned a {type(dependency_result)},'
                'it should return a Parameter or DescriptorNumber.'
            )  # noqa: E501
        # Check for cyclic dependencies
        try:
            self._validate_dependencies()
        except RuntimeError as error:
            self._revert_dependency()
            raise error
        # Update the parameter with the dependency result
        self._fixed = False

        if self._desired_unit is not None:
            try:
                dependency_result._convert_unit(self._desired_unit)
            except Exception as e:
                desired_unit_for_error_message = self._desired_unit
                self._revert_dependency()  # also deletes self._desired_unit
                raise UnitError(
                    f'Failed to convert unit from {dependency_result.unit} to {desired_unit_for_error_message}: {e}'
                )

        self._update()

    def make_independent(self) -> None:
        """Make this parameter independent. This will remove the
        dependency expression, the dependency map and the dependency
        interpreter.

        :return: None
        """
        if not self._independent:
            for dependency in self._dependency_map.values():
                dependency._detach_observer(self)
            self._independent = True
            del self._dependency_map
            del self._dependency_interpreter
            del self._dependency_string
            del self._clean_dependency_string
            del self._desired_unit
        else:
            raise AttributeError('This parameter is already independent.')

    @property
    def independent(self) -> bool:
        """Is the parameter independent?

        :return: True = independent, False = dependent
        """
        return self._independent

    @independent.setter
    def independent(self, value: bool) -> None:
        raise AttributeError(
            'This property is read-only. Use `make_independent` and  `make_dependent_on` to change the state of the parameter.'
        )  # noqa: E501

    @property
    def dependency_expression(self) -> str:
        """Get the dependency expression of this parameter.

        :return: The dependency expression of this parameter.
        """
        if not self._independent:
            return self._dependency_string
        else:
            raise AttributeError('This parameter is independent. It has no dependency expression.')

    @dependency_expression.setter
    def dependency_expression(self, new_expression: str) -> None:
        raise AttributeError(
            'Dependency expression is read-only. Use `make_dependent_on` to change the dependency expression.'
        )  # noqa: E501

    @property
    def dependency_map(self) -> Dict[str, DescriptorNumber]:
        """Get the dependency map of this parameter.

        :return: The dependency map of this parameter.
        """
        if not self._independent:
            return self._dependency_map
        else:
            raise AttributeError('This parameter is independent. It has no dependency map.')

    @dependency_map.setter
    def dependency_map(self, new_map: Dict[str, DescriptorNumber]) -> None:
        raise AttributeError(
            'Dependency map is read-only. Use `make_dependent_on` to change the dependency map.'
        )

    @property
    def value_no_call_back(self) -> numbers.Number:
        """Get the currently hold value of self suppressing call back.

        :return: Value of self without unit.
        """
        return self._scalar.value

    @property
    def full_value(self) -> Variable:
        """Get the value of self as a scipp scalar. This is should be
        usable for most cases. If a scipp scalar is not acceptable then
        the raw value can be obtained through `obj.value`.

        :return: Value of self with unit and variance.
        """
        return self._scalar

    @full_value.setter
    def full_value(self, scalar: Variable) -> None:
        raise AttributeError(
            f'Full_value is read-only. Change the value and variance seperately. Or create a new {self.__class__.__name__}.'
        )  # noqa: E501

    @property
    def value(self) -> numbers.Number:
        """Get the value of self as a Number.

        :return: Value of self without unit.
        """
        if self._callback.fget is not None:
            existing_value = self._callback.fget()
            if existing_value != self._scalar.value:
                self._scalar.value = existing_value
        return self._scalar.value

    @value.setter
    @property_stack
    def value(self, value: numbers.Number) -> None:
        """Set the value of self. This only updates the value of the
        scipp scalar.

        :param value: New value of self
        """
        if self._independent:
            if not isinstance(value, numbers.Number):
                raise TypeError(f'{value=} must be a number')

            value = float(value)
            if value < self._min.value:
                value = self._min.value
            if value > self._max.value:
                value = self._max.value

            self._scalar.value = value

            if self._callback.fset is not None:
                self._callback.fset(self._scalar.value)

            # Notify observers of the change
            self._notify_observers()
        else:
            raise AttributeError(
                'This is a dependent parameter, its value cannot be set directly.'
            )

    @DescriptorNumber.variance.setter
    def variance(self, variance_float: float) -> None:
        """Set the variance.

        :param variance_float: Variance as a float
        """
        if self._independent:
            DescriptorNumber.variance.fset(self, variance_float)
        else:
            raise AttributeError(
                'This is a dependent parameter, its variance cannot be set directly.'
            )

    @DescriptorNumber.error.setter
    def error(self, value: float) -> None:
        """Set the standard deviation for the parameter.

        :param value: New error value
        """
        if self._independent:
            DescriptorNumber.error.fset(self, value)
        else:
            raise AttributeError(
                'This is a dependent parameter, its error cannot be set directly.'
            )

    def _convert_unit(self, unit_str: str) -> None:
        """Perform unit conversion. The value, max and min can change on
        unit change.

        :param new_unit: new unit
        :return: None
        """
        super()._convert_unit(unit_str=unit_str)
        new_unit = sc.Unit(unit_str)  # unit_str is tested in super method
        self._min = self._min.to(unit=new_unit)
        self._max = self._max.to(unit=new_unit)

    @notify_observers
    def convert_unit(self, unit_str: str) -> None:
        """Perform unit conversion. The value, max and min can change on
        unit change.

        :param new_unit: new unit
        :return: None
        """
        self._convert_unit(unit_str)

    def set_desired_unit(self, unit_str: str | sc.Unit | None) -> None:
        """Set the desired unit for a dependent Parameter. This will
        convert the parameter to the desired unit.

        :param unit_str: The desired unit as a string.
        """

        if self._independent:
            raise AttributeError(
                'This is an independent parameter, desired unit can only be set for dependent parameters.'
            )
        if not (isinstance(unit_str, str) or isinstance(unit_str, sc.Unit) or unit_str is None):
            raise TypeError('`unit_str` must be a string representing a valid unit.')

        if unit_str is not None:
            try:
                old_unit_for_message = self.unit
                self._convert_unit(unit_str)
            except Exception as e:
                raise UnitError(
                    f'Failed to convert unit from {old_unit_for_message} to {unit_str}: {e}'
                )

        self._desired_unit = unit_str
        self._update()

    @property
    def min(self) -> numbers.Number:
        """Get the minimum value for fitting.

        :return: minimum value
        """
        return self._min.value

    @min.setter
    @property_stack
    def min(self, min_value: numbers.Number) -> None:
        """
        Set the minimum value for fitting.
        - implements undo/redo functionality.

        :param min_value: new minimum value
        :return: None
        """
        if self._independent:
            if not isinstance(min_value, numbers.Number):
                raise TypeError('`min` must be a number')
            if np.isclose(min_value, self._max.value, rtol=1e-9, atol=0.0):
                raise ValueError(
                    'The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.'
                )
            if min_value <= self.value:
                self._min.value = min_value
            else:
                raise ValueError(
                    f'The current value ({self.value}) is smaller than the desired min value ({min_value}).'
                )
            self._notify_observers()
        else:
            raise AttributeError(
                'This is a dependent parameter, its minimum value cannot be set directly.'
            )

    @property
    def max(self) -> numbers.Number:
        """Get the maximum value for fitting.

        :return: maximum value
        """
        return self._max.value

    @max.setter
    @property_stack
    def max(self, max_value: numbers.Number) -> None:
        """
        Get the maximum value for fitting.
        - implements undo/redo functionality.

        :param max_value: new maximum value
        :return: None
        """
        if self._independent:
            if not isinstance(max_value, numbers.Number):
                raise TypeError('`max` must be a number')
            if np.isclose(max_value, self._min.value, rtol=1e-9, atol=0.0):
                raise ValueError(
                    'The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.'
                )
            if max_value >= self.value:
                self._max.value = max_value
            else:
                raise ValueError(
                    f'The current value ({self.value}) is greater than the desired max value ({max_value}).'
                )
            self._notify_observers()
        else:
            raise AttributeError(
                'This is a dependent parameter, its maximum value cannot be set directly.'
            )

    @property
    def fixed(self) -> bool:
        """Can the parameter vary while fitting?

        :return: True = fixed, False = can vary
        """
        return self._fixed

    @fixed.setter
    @property_stack
    def fixed(self, fixed: bool) -> None:
        """
        Change the parameter vary while fitting state.
        - implements undo/redo functionality.

        :param fixed: True = fixed, False = can vary
        """
        if not isinstance(fixed, bool):
            raise ValueError(f'{fixed=} must be a boolean. Got {type(fixed)}')
        if self._independent:
            self._fixed = fixed
        else:
            if self._global_object.stack.enabled:
                # Remove the recorded change from the stack
                global_object.stack.pop()
            raise AttributeError(
                'This is a dependent parameter, dependent parameters cannot be fixed.'
            )

    # Is this alias really needed?
    @property
    def free(self) -> bool:
        return not self.fixed

    @free.setter
    def free(self, value: bool) -> None:
        self.fixed = not value

    def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
        """Overwrite the as_dict method to handle dependency
        information.
        """
        raw_dict = super().as_dict(skip=skip)

        # Add dependency information for dependent parameters
        if not self._independent:
            # Save the dependency expression
            raw_dict['_dependency_string'] = self._clean_dependency_string

            if self._desired_unit is not None:
                raw_dict['_desired_unit'] = self._desired_unit

            # Mark that this parameter is dependent
            raw_dict['_independent'] = self._independent

            # Convert dependency_map to use serializer_ids
            raw_dict['_dependency_map_serializer_ids'] = {}
            for key, obj in self._dependency_map.items():
                raw_dict['_dependency_map_serializer_ids'][key] = (
                    obj._DescriptorNumber__serializer_id
                )

        return raw_dict

    def _revert_dependency(self, skip_detach=False) -> None:
        """Revert the dependency to the old dependency.

        This is used when an error is raised during setting the
        dependency.
        """
        if self._previous_independent is True:
            self.make_independent()
        else:
            if not skip_detach:
                for dependency in self._dependency_map.values():
                    dependency._detach_observer(self)
            for key, value in self._previous_dependency.items():
                setattr(self, key, value)
            for dependency in self._dependency_map.values():
                dependency._attach_observer(self)
            del self._previous_dependency
        del self._previous_independent

    def _process_dependency_unique_names(self, dependency_expression: str):
        """Add the unique names of the parameters to the ASTEval
        interpreter. This is used to evaluate the dependency expression.

        :param dependency_expression: The dependency expression to be
            evaluated
        """
        # Get the unique_names from the expression string regardless of the quotes used
        inputted_unique_names = re.findall("('.+?')", dependency_expression)
        inputted_unique_names += re.findall('(".+?")', dependency_expression)

        clean_dependency_string = dependency_expression
        existing_unique_names = self._global_object.map.vertices()
        # Add the unique names of the parameters to the ASTEVAL interpreter
        for name in inputted_unique_names:
            stripped_name = name.strip('\'"')
            if stripped_name not in existing_unique_names:
                raise ValueError(
                    f'A Parameter with unique_name {stripped_name} does not exist. Please check your dependency expression.'
                )  # noqa: E501
            dependent_parameter = self._global_object.map.get_item_by_key(stripped_name)
            if isinstance(dependent_parameter, DescriptorNumber):
                self._dependency_map['__' + stripped_name + '__'] = dependent_parameter
                clean_dependency_string = clean_dependency_string.replace(
                    name, '__' + stripped_name + '__'
                )
            else:
                raise ValueError(
                    f'The object with unique_name {stripped_name} is not a Parameter or DescriptorNumber. '
                    'Please check your dependency expression.'
                )  # noqa: E501
        self._clean_dependency_string = clean_dependency_string

    @classmethod
    def from_dict(cls, obj_dict: dict) -> 'Parameter':
        """Custom deserialization to handle parameter dependencies.

        Override the parent method to handle dependency information.
        """
        # Extract dependency information before creating the parameter
        raw_dict = obj_dict.copy()  # Don't modify the original dict
        dependency_string = raw_dict.pop('_dependency_string', None)
        dependency_map_serializer_ids = raw_dict.pop('_dependency_map_serializer_ids', None)
        is_independent = raw_dict.pop('_independent', True)
        desired_unit = raw_dict.pop('_desired_unit', None)
        # Note: Keep _serializer_id in the dict so it gets passed to __init__

        # Create the parameter using the base class method (serializer_id is now handled in __init__)
        param = super().from_dict(raw_dict)

        # Store dependency information for later resolution
        if not is_independent:
            param._pending_dependency_string = dependency_string
            param._pending_dependency_map_serializer_ids = dependency_map_serializer_ids
            # Keep parameter as independent initially - will be made dependent after all objects are loaded
            param._independent = True
            param._pending_desired_unit = desired_unit

        return param

    def __copy__(self) -> Parameter:
        new_obj = super().__copy__()
        new_obj._callback = property()
        return new_obj

    def __repr__(self) -> str:
        """Return printable representation of a Parameter object."""
        super_str = super().__repr__()
        super_str = super_str[:-1]
        s = []
        if self.fixed:
            super_str += ' (fixed)'
        s.append(super_str)
        s.append('bounds=[%s:%s]' % (repr(float(self.min)), repr(float(self.max))))
        return '%s>' % ', '.join(s)

    # Seems redundant
    # def __float__(self) -> float:
    #     return float(self._scalar.value)

    def __add__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> Parameter:
        if isinstance(other, numbers.Number):
            if self.unit != 'dimensionless':
                raise UnitError('Numbers can only be added to dimensionless values')
            new_full_value = self.full_value + other
            min_value = self.min + other
            max_value = self.max + other
        elif isinstance(
            other, DescriptorNumber
        ):  # Parameter inherits from DescriptorNumber and is also handled here
            other_unit = other.unit
            try:
                other._convert_unit(self.unit)
            except UnitError:
                raise UnitError(
                    f'Values with units {self.unit} and {other.unit} cannot be added'
                ) from None
            new_full_value = self.full_value + other.full_value
            min_value = (
                self.min + other.min if isinstance(other, Parameter) else self.min + other.value
            )
            max_value = (
                self.max + other.max if isinstance(other, Parameter) else self.max + other.value
            )
            other._convert_unit(other_unit)
        else:
            return NotImplemented
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter.name = parameter.unique_name
        return parameter

    def __radd__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
        if isinstance(other, numbers.Number):
            if self.unit != 'dimensionless':
                raise UnitError('Numbers can only be added to dimensionless values')
            new_full_value = self.full_value + other
            min_value = self.min + other
            max_value = self.max + other
        elif isinstance(
            other, DescriptorNumber
        ):  # Parameter inherits from DescriptorNumber and is also handled here
            original_unit = self.unit
            try:
                self._convert_unit(other.unit)
            except UnitError:
                raise UnitError(
                    f'Values with units {other.unit} and {self.unit} cannot be added'
                ) from None
            new_full_value = self.full_value + other.full_value
            min_value = self.min + other.value
            max_value = self.max + other.value
            self._convert_unit(original_unit)
        else:
            return NotImplemented
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter.name = parameter.unique_name
        return parameter

    def __sub__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> Parameter:
        if isinstance(other, numbers.Number):
            if self.unit != 'dimensionless':
                raise UnitError('Numbers can only be subtracted from dimensionless values')
            new_full_value = self.full_value - other
            min_value = self.min - other
            max_value = self.max - other
        elif isinstance(
            other, DescriptorNumber
        ):  # Parameter inherits from DescriptorNumber and is also handled here
            other_unit = other.unit
            try:
                other._convert_unit(self.unit)
            except UnitError:
                raise UnitError(
                    f'Values with units {self.unit} and {other.unit} cannot be subtracted'
                ) from None
            new_full_value = self.full_value - other.full_value
            if isinstance(other, Parameter):
                min_value = self.min - other.max if other.max != np.inf else -np.inf
                max_value = self.max - other.min if other.min != -np.inf else np.inf
            else:
                min_value = self.min - other.value
                max_value = self.max - other.value
            other._convert_unit(other_unit)
        else:
            return NotImplemented
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter.name = parameter.unique_name
        return parameter

    def __rsub__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
        if isinstance(other, numbers.Number):
            if self.unit != 'dimensionless':
                raise UnitError('Numbers can only be subtracted from dimensionless values')
            new_full_value = other - self.full_value
            min_value = other - self.max
            max_value = other - self.min
        elif isinstance(
            other, DescriptorNumber
        ):  # Parameter inherits from DescriptorNumber and is also handled here
            original_unit = self.unit
            try:
                self._convert_unit(other.unit)
            except UnitError:
                raise UnitError(
                    f'Values with units {other.unit} and {self.unit} cannot be subtracted'
                ) from None
            new_full_value = other.full_value - self.full_value
            min_value = other.value - self.max
            max_value = other.value - self.min
            self._convert_unit(original_unit)
        else:
            return NotImplemented
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter.name = parameter.unique_name
        return parameter

    def __mul__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> Parameter:
        if isinstance(other, numbers.Number):
            new_full_value = self.full_value * other
            if other == 0:
                descriptor_number = DescriptorNumber.from_scipp(
                    name=self.name, full_value=new_full_value
                )
                descriptor_number.name = descriptor_number.unique_name
                return descriptor_number
            combinations = [self.min * other, self.max * other]
        elif isinstance(
            other, DescriptorNumber
        ):  # Parameter inherits from DescriptorNumber and is also handled here
            new_full_value = self.full_value * other.full_value
            if (
                other.value == 0 and type(other) is DescriptorNumber
            ):  # Only return DescriptorNumber if other is strictly 0, i.e. not a parameter  # noqa: E501
                descriptor_number = DescriptorNumber.from_scipp(
                    name=self.name, full_value=new_full_value
                )
                descriptor_number.name = descriptor_number.unique_name
                return descriptor_number
            if isinstance(other, Parameter):
                combinations = []
                for first, second in [
                    (self.min, other.min),
                    (self.min, other.max),
                    (self.max, other.min),
                    (self.max, other.max),
                ]:  # noqa: E501
                    if first == 0 and np.isinf(second):
                        combinations.append(0)
                    elif second == 0 and np.isinf(first):
                        combinations.append(0)
                    else:
                        combinations.append(first * second)
            else:
                combinations = [self.min * other.value, self.max * other.value]
        else:
            return NotImplemented
        min_value = min(combinations)
        max_value = max(combinations)
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter._convert_unit(parameter._base_unit())
        parameter.name = parameter.unique_name
        return parameter

    def __rmul__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
        if isinstance(other, numbers.Number):
            new_full_value = other * self.full_value
            if other == 0:
                descriptor_number = DescriptorNumber.from_scipp(
                    name=self.name, full_value=new_full_value
                )
                descriptor_number.name = descriptor_number.unique_name
                return descriptor_number
            combinations = [other * self.min, other * self.max]
        elif isinstance(
            other, DescriptorNumber
        ):  # Parameter inherits from DescriptorNumber and is also handled here
            new_full_value = other.full_value * self.full_value
            if other.value == 0:
                descriptor_number = DescriptorNumber.from_scipp(
                    name=self.name, full_value=new_full_value
                )
                descriptor_number.name = descriptor_number.unique_name
                return descriptor_number
            combinations = [self.min * other.value, self.max * other.value]
        else:
            return NotImplemented
        min_value = min(combinations)
        max_value = max(combinations)
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter._convert_unit(parameter._base_unit())
        parameter.name = parameter.unique_name
        return parameter

    def __truediv__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> Parameter:
        if isinstance(other, numbers.Number):
            if other == 0:
                raise ZeroDivisionError('Cannot divide by zero')
            new_full_value = self.full_value / other
            combinations = [self.min / other, self.max / other]
        elif isinstance(
            other, DescriptorNumber
        ):  # Parameter inherits from DescriptorNumber and is also handled here
            other_value = other.value
            if other_value == 0:
                raise ZeroDivisionError('Cannot divide by zero')
            new_full_value = self.full_value / other.full_value
            if isinstance(other, Parameter):
                if other.min < 0 and other.max > 0:
                    combinations = [-np.inf, np.inf]
                elif other.min == 0:
                    if self.min < 0 and self.max > 0:
                        combinations = [-np.inf, np.inf]
                    elif self.min >= 0:
                        combinations = [self.min / other.max, np.inf]
                    elif self.max <= 0:
                        combinations = [-np.inf, self.max / other.max]
                elif other.max == 0:
                    if self.min < 0 and self.max > 0:
                        combinations = [-np.inf, np.inf]
                    elif self.min >= 0:
                        combinations = [-np.inf, self.min / other.min]
                    elif self.max <= 0:
                        combinations = [self.max / other.min, np.inf]
                else:
                    combinations = [
                        self.min / other.min,
                        self.max / other.max,
                        self.min / other.max,
                        self.max / other.min,
                    ]
            else:
                combinations = [self.min / other.value, self.max / other.value]
        else:
            return NotImplemented
        min_value = min(combinations)
        max_value = max(combinations)
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter._convert_unit(parameter._base_unit())
        parameter.name = parameter.unique_name
        return parameter

    def __rtruediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
        original_self = self.value
        if original_self == 0:
            raise ZeroDivisionError('Cannot divide by zero')
        if isinstance(other, numbers.Number):
            new_full_value = other / self.full_value
            other_value = other
            if other_value == 0:
                descriptor_number = DescriptorNumber.from_scipp(
                    name=self.name, full_value=new_full_value
                )
                descriptor_number.name = descriptor_number.unique_name
                return descriptor_number
        elif isinstance(
            other, DescriptorNumber
        ):  # Parameter inherits from DescriptorNumber and is also handled here
            new_full_value = other.full_value / self.full_value
            other_value = other.value
            if other_value == 0:
                descriptor_number = DescriptorNumber.from_scipp(
                    name=self.name, full_value=new_full_value
                )
                descriptor_number.name = descriptor_number.unique_name
                return descriptor_number
        else:
            return NotImplemented
        if self.min < 0 and self.max > 0:
            combinations = [-np.inf, np.inf]
        elif self.min == 0:
            if other_value > 0:
                combinations = [other_value / self.max, np.inf]
            elif other_value < 0:
                combinations = [-np.inf, other_value / self.max]
        elif self.max == 0:
            if other_value > 0:
                combinations = [-np.inf, other_value / self.min]
            elif other_value < 0:
                combinations = [other_value / self.min, np.inf]
        else:
            combinations = [other_value / self.min, other_value / self.max]
        min_value = min(combinations)
        max_value = max(combinations)
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter._convert_unit(parameter._base_unit())
        parameter.name = parameter.unique_name
        return parameter

    def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
        if isinstance(other, numbers.Number):
            exponent = other
        elif (
            type(other) is DescriptorNumber
        ):  # Strictly a DescriptorNumber, We can't raise to the power of a Parameter
            if other.unit != 'dimensionless':
                raise UnitError('Exponents must be dimensionless')
            if other.variance is not None:
                raise ValueError('Exponents must not have variance')
            exponent = other.value
        else:
            return NotImplemented

        try:
            new_full_value = self.full_value**exponent
        except Exception as message:
            raise message from None

        if np.isnan(new_full_value.value):
            raise ValueError('The result of the exponentiation is not a number')
        if exponent == 0:
            descriptor_number = DescriptorNumber.from_scipp(
                name=self.name, full_value=new_full_value
            )
            descriptor_number.name = descriptor_number.unique_name
            return descriptor_number
        elif exponent < 0:
            if self.min < 0 and self.max > 0:
                combinations = [-np.inf, np.inf]
            elif self.min == 0:
                combinations = [self.max**exponent, np.inf]
            elif self.max == 0:
                combinations = [-np.inf, self.min**exponent]
            else:
                combinations = [self.min**exponent, self.max**exponent]
        else:
            combinations = [self.min**exponent, self.max**exponent]
        if exponent % 2 == 0:
            if self.min < 0 and self.max > 0:
                combinations.append(0)
            combinations = [abs(combination) for combination in combinations]
        elif exponent % 1 != 0:
            if self.min < 0:
                combinations.append(0)
            combinations = [combination for combination in combinations if combination >= 0]
        min_value = min(combinations)
        max_value = max(combinations)
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter.name = parameter.unique_name
        return parameter

    def __neg__(self) -> Parameter:
        new_full_value = -self.full_value
        min_value = -self.max
        max_value = -self.min
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter.name = parameter.unique_name
        return parameter

    def __abs__(self) -> Parameter:
        new_full_value = abs(self.full_value)
        combinations = [abs(self.min), abs(self.max)]
        if self.min < 0 and self.max > 0:
            combinations.append(0.0)
        min_value = min(combinations)
        max_value = max(combinations)
        parameter = Parameter.from_scipp(
            name=self.name, full_value=new_full_value, min=min_value, max=max_value
        )
        parameter.name = parameter.unique_name
        return parameter

    def resolve_pending_dependencies(self) -> None:
        """Resolve pending dependencies after deserialization.

        This method should be called after all parameters have been
        deserialized to establish dependency relationships using
        serializer_ids.
        """
        if hasattr(self, '_pending_dependency_string'):
            dependency_string = self._pending_dependency_string
            dependency_map = {}

            if hasattr(self, '_pending_dependency_map_serializer_ids'):
                dependency_map_serializer_ids = self._pending_dependency_map_serializer_ids

                # Build dependency_map by looking up objects by serializer_id
                for key, serializer_id in dependency_map_serializer_ids.items():
                    dep_obj = self._find_parameter_by_serializer_id(serializer_id)
                    if dep_obj is not None:
                        dependency_map[key] = dep_obj
                    else:
                        raise ValueError(
                            f"Cannot find parameter with serializer_id '{serializer_id}'"
                        )

            # Establish the dependency relationship
            try:
                self.make_dependent_on(
                    dependency_expression=dependency_string,
                    dependency_map=dependency_map,
                    desired_unit=self._pending_desired_unit,
                )
            except Exception as e:
                raise ValueError(f"Error establishing dependency '{dependency_string}': {e}")

            # Clean up temporary attributes
            delattr(self, '_pending_dependency_string')
            delattr(self, '_pending_dependency_map_serializer_ids')
            delattr(self, '_pending_desired_unit')

    def _find_parameter_by_serializer_id(self, serializer_id: str) -> Optional['DescriptorNumber']:
        """Find a parameter by its serializer_id from all parameters in
        the global map.
        """
        for obj in self._global_object.map._store.values():
            if isinstance(obj, DescriptorNumber) and hasattr(
                obj, '_DescriptorNumber__serializer_id'
            ):
                if obj._DescriptorNumber__serializer_id == serializer_id:
                    return obj
        return None

__init__(name, value, unit='', variance=0.0, min=-np.inf, max=np.inf, fixed=False, unique_name=None, description=None, url=None, display_name=None, callback=property(), parent=None, **kwargs)

This class is an extension of a DescriptorNumber. Where the descriptor was for static objects, a Parameter is for dynamic objects. A parameter has the ability to be used in fitting and has additional fields to facilitate this.

:param name: Name of this object :param value: Value of this object :param unit: This object can have a physical unit associated with it :param variance: The variance of the value :param min: The minimum value for fitting :param max: The maximum value for fitting :param fixed: If the parameter is free to vary during fitting :param description: A brief summary of what this object is :param url: Lookup url for documentation/information :param display_name: The name of the object as it should be displayed :param parent: The object which is the parent to this one

Note

Undo/Redo functionality is implemented for the attributes value, variance, error, min, max, bounds, fixed, unit

Source code in src/easyscience/variable/parameter.py
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def __init__(
    self,
    name: str,
    value: numbers.Number,
    unit: Optional[Union[str, sc.Unit]] = '',
    variance: Optional[numbers.Number] = 0.0,
    min: Optional[numbers.Number] = -np.inf,
    max: Optional[numbers.Number] = np.inf,
    fixed: Optional[bool] = False,
    unique_name: Optional[str] = None,
    description: Optional[str] = None,
    url: Optional[str] = None,
    display_name: Optional[str] = None,
    callback: property = property(),
    parent: Optional[Any] = None,
    **kwargs: Any,  # Additional keyword arguments (used for (de)serialization)
):
    """This class is an extension of a `DescriptorNumber`. Where the
    descriptor was for static objects, a `Parameter` is for dynamic
    objects. A parameter has the ability to be used in fitting and
    has additional fields to facilitate this.

    :param name: Name of this object
    :param value: Value of this object
    :param unit: This object can have a physical unit associated with it
    :param variance: The variance of the value
    :param min: The minimum value for fitting
    :param max: The maximum value for fitting
    :param fixed: If the parameter is free to vary during fitting
    :param description: A brief summary of what this object is
    :param url: Lookup url for documentation/information
    :param display_name: The name of the object as it should be displayed
    :param parent: The object which is the parent to this one

    Note:
        Undo/Redo functionality is implemented for the attributes `value`, `variance`, `error`, `min`, `max`, `bounds`, `fixed`, `unit`
    """  # noqa: E501
    # Extract and ignore serialization-specific fields from kwargs
    kwargs.pop('_dependency_string', None)
    kwargs.pop('_dependency_map_serializer_ids', None)
    kwargs.pop('_independent', None)

    if not isinstance(min, numbers.Number):
        raise TypeError('`min` must be a number')
    if not isinstance(max, numbers.Number):
        raise TypeError('`max` must be a number')
    if not isinstance(value, numbers.Number):
        raise TypeError('`value` must be a number')
    if value < min:
        raise ValueError(f'{value=} can not be less than {min=}')
    if value > max:
        raise ValueError(f'{value=} can not be greater than {max=}')
    if np.isclose(min, max, rtol=1e-9, atol=0.0):
        raise ValueError(
            'The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.'
        )
    if not isinstance(fixed, bool):
        raise TypeError('`fixed` must be either True or False')
    self._independent = True
    self._fixed = fixed  # For fitting, but must be initialized before super().__init__
    self._min = sc.scalar(float(min), unit=unit)
    self._max = sc.scalar(float(max), unit=unit)

    super().__init__(
        name=name,
        value=value,
        unit=unit,
        variance=variance,
        unique_name=unique_name,
        description=description,
        url=url,
        display_name=display_name,
        parent=parent,
        **kwargs,  # Additional keyword arguments (used for (de)serialization)
    )

    self._callback = callback  # Callback is used by interface to link to model
    if self._callback.fdel is not None:
        weakref.finalize(self, self._callback.fdel)

    # Create additional fitting elements
    self._initial_scalar = copy.deepcopy(self._scalar)

__repr__()

Return printable representation of a Parameter object.

Source code in src/easyscience/variable/parameter.py
779
780
781
782
783
784
785
786
787
788
def __repr__(self) -> str:
    """Return printable representation of a Parameter object."""
    super_str = super().__repr__()
    super_str = super_str[:-1]
    s = []
    if self.fixed:
        super_str += ' (fixed)'
    s.append(super_str)
    s.append('bounds=[%s:%s]' % (repr(float(self.min)), repr(float(self.max))))
    return '%s>' % ', '.join(s)

as_dict(skip=None)

Overwrite the as_dict method to handle dependency information.

Source code in src/easyscience/variable/parameter.py
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
    """Overwrite the as_dict method to handle dependency
    information.
    """
    raw_dict = super().as_dict(skip=skip)

    # Add dependency information for dependent parameters
    if not self._independent:
        # Save the dependency expression
        raw_dict['_dependency_string'] = self._clean_dependency_string

        if self._desired_unit is not None:
            raw_dict['_desired_unit'] = self._desired_unit

        # Mark that this parameter is dependent
        raw_dict['_independent'] = self._independent

        # Convert dependency_map to use serializer_ids
        raw_dict['_dependency_map_serializer_ids'] = {}
        for key, obj in self._dependency_map.items():
            raw_dict['_dependency_map_serializer_ids'][key] = (
                obj._DescriptorNumber__serializer_id
            )

    return raw_dict

convert_unit(unit_str)

Perform unit conversion. The value, max and min can change on unit change.

:param new_unit: new unit :return: None

Source code in src/easyscience/variable/parameter.py
521
522
523
524
525
526
527
528
529
@notify_observers
def convert_unit(self, unit_str: str) -> None:
    """Perform unit conversion. The value, max and min can change on
    unit change.

    :param new_unit: new unit
    :return: None
    """
    self._convert_unit(unit_str)

dependency_expression property writable

Get the dependency expression of this parameter.

:return: The dependency expression of this parameter.

dependency_map property writable

Get the dependency map of this parameter.

:return: The dependency map of this parameter.

error(value)

Set the standard deviation for the parameter.

:param value: New error value

Source code in src/easyscience/variable/parameter.py
496
497
498
499
500
501
502
503
504
505
506
507
@DescriptorNumber.error.setter
def error(self, value: float) -> None:
    """Set the standard deviation for the parameter.

    :param value: New error value
    """
    if self._independent:
        DescriptorNumber.error.fset(self, value)
    else:
        raise AttributeError(
            'This is a dependent parameter, its error cannot be set directly.'
        )

fixed property writable

Can the parameter vary while fitting?

:return: True = fixed, False = can vary

from_dependency(name, dependency_expression, dependency_map=None, desired_unit=None, **kwargs) classmethod

Create a dependent Parameter directly from a dependency expression.

:param name: The name of the parameter :param dependency_expression: The dependency expression to evaluate. This should be a string which can be evaluated by the ASTEval interpreter. :param dependency_map: A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies. :param desired_unit: The desired unit of the dependent parameter. :param kwargs: Additional keyword arguments to pass to the Parameter constructor. :return: A new dependent Parameter object.

Source code in src/easyscience/variable/parameter.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
@classmethod
def from_dependency(
    cls,
    name: str,
    dependency_expression: str,
    dependency_map: Optional[dict] = None,
    desired_unit: str | sc.Unit | None = None,
    **kwargs,
) -> Parameter:  # noqa: E501
    """Create a dependent Parameter directly from a dependency
    expression.

    :param name: The name of the parameter
    :param dependency_expression: The dependency expression to
        evaluate. This should be a string which can be evaluated by
        the ASTEval interpreter.
    :param dependency_map: A dictionary of dependency expression
        symbol name and dependency object pairs. This is inserted
        into the asteval interpreter to resolve dependencies.
    :param desired_unit: The desired unit of the dependent
        parameter.
    :param kwargs: Additional keyword arguments to pass to the
        Parameter constructor.
    :return: A new dependent Parameter object.
    """  # noqa: E501
    # Set default values for required parameters for the constructor, they get overwritten by the dependency anyways
    default_kwargs = {'value': 0.0, 'variance': 0.0, 'min': -np.inf, 'max': np.inf}
    # Update with user-provided kwargs, to avoid errors.
    default_kwargs.update(kwargs)
    parameter = cls(name=name, **default_kwargs)
    parameter.make_dependent_on(
        dependency_expression=dependency_expression,
        dependency_map=dependency_map,
        desired_unit=desired_unit,
    )
    return parameter

from_dict(obj_dict) classmethod

Custom deserialization to handle parameter dependencies.

Override the parent method to handle dependency information.

Source code in src/easyscience/variable/parameter.py
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
@classmethod
def from_dict(cls, obj_dict: dict) -> 'Parameter':
    """Custom deserialization to handle parameter dependencies.

    Override the parent method to handle dependency information.
    """
    # Extract dependency information before creating the parameter
    raw_dict = obj_dict.copy()  # Don't modify the original dict
    dependency_string = raw_dict.pop('_dependency_string', None)
    dependency_map_serializer_ids = raw_dict.pop('_dependency_map_serializer_ids', None)
    is_independent = raw_dict.pop('_independent', True)
    desired_unit = raw_dict.pop('_desired_unit', None)
    # Note: Keep _serializer_id in the dict so it gets passed to __init__

    # Create the parameter using the base class method (serializer_id is now handled in __init__)
    param = super().from_dict(raw_dict)

    # Store dependency information for later resolution
    if not is_independent:
        param._pending_dependency_string = dependency_string
        param._pending_dependency_map_serializer_ids = dependency_map_serializer_ids
        # Keep parameter as independent initially - will be made dependent after all objects are loaded
        param._independent = True
        param._pending_desired_unit = desired_unit

    return param

full_value property writable

Get the value of self as a scipp scalar. This is should be usable for most cases. If a scipp scalar is not acceptable then the raw value can be obtained through obj.value.

:return: Value of self with unit and variance.

independent property writable

Is the parameter independent?

:return: True = independent, False = dependent

make_dependent_on(dependency_expression, dependency_map=None, desired_unit=None)

Make this parameter dependent on another parameter. This will overwrite the current value, unit, variance, min and max.

How to use the dependency map: If a parameter c has a dependency expression of 'a + b', where a and b are parameters belonging to the model class, then the dependency map needs to have the form {'a': model.a, 'b': model.b}, where model is the model class. I.e. the values are the actual objects, whereas the keys are how they are represented in the dependency expression.

The dependency map is not needed if the dependency expression uses the unique names of the parameters. Unique names in dependency expressions are defined by quotes, e.g. 'Parameter_0' or "Parameter_0" depending on the quotes used for the expression.

:param dependency_expression: The dependency expression to evaluate. This should be a string which can be evaluated by a python interpreter.

:param dependency_map: A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies.

:param desired_unit: The desired unit of the dependent parameter. If None, the default unit of the dependency expression result is used.

Source code in src/easyscience/variable/parameter.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def make_dependent_on(
    self,
    dependency_expression: str,
    dependency_map: Optional[dict] = None,
    desired_unit: str | sc.Unit | None = None,
) -> None:
    """Make this parameter dependent on another parameter. This will
    overwrite the current value, unit, variance, min and max.

    How to use the dependency map:
    If a parameter c has a dependency expression of 'a + b', where a and b are parameters belonging to the model class,
    then the dependency map needs to have the form {'a': model.a, 'b': model.b}, where model is the model class.
    I.e. the values are the actual objects, whereas the keys are how they are represented in the dependency expression.

    The dependency map is not needed if the dependency expression uses the unique names of the parameters.
    Unique names in dependency expressions are defined by quotes, e.g. 'Parameter_0' or "Parameter_0" depending on
    the quotes used for the expression.

    :param dependency_expression:
        The dependency expression to evaluate. This should be a string which
        can be evaluated by a python interpreter.

    :param dependency_map:
        A dictionary of dependency expression symbol name and dependency object pairs.
        This is inserted into the asteval interpreter to resolve dependencies.

    :param desired_unit:
        The desired unit of the dependent parameter. If None, the default unit of the dependency expression result is used.
    """  # noqa: E501
    if not isinstance(dependency_expression, str):
        raise TypeError(
            '`dependency_expression` must be a string representing a valid dependency expression.'
        )
    if not (isinstance(dependency_map, dict) or dependency_map is None):
        raise TypeError(
            '`dependency_map` must be a dictionary of dependencies and their'
            'corresponding names in the dependecy expression.'
        )  # noqa: E501
    if isinstance(dependency_map, dict):
        for key, value in dependency_map.items():
            if not isinstance(key, str):
                raise TypeError(
                    '`dependency_map` keys must be strings representing the names of'
                    'the dependencies in the dependency expression.'
                )  # noqa: E501
            if not isinstance(value, DescriptorNumber):
                raise TypeError(
                    f'`dependency_map` values must be DescriptorNumbers or Parameters. Got {type(value)} for {key}.'
                )  # noqa: E501

    # If we're overwriting the dependency, store the old attributes
    # in case we need to revert back to the old dependency
    self._previous_independent = self._independent
    if not self._independent:
        self._previous_dependency = {
            '_dependency_string': self._dependency_string,
            '_dependency_map': self._dependency_map,
            '_dependency_interpreter': self._dependency_interpreter,
            '_clean_dependency_string': self._clean_dependency_string,
            '_desired_unit': self._desired_unit,
        }
        for dependency in self._dependency_map.values():
            dependency._detach_observer(self)

    self._independent = False
    self._dependency_string = dependency_expression
    self._dependency_map = dependency_map if dependency_map is not None else {}
    if desired_unit is not None and not (
        isinstance(desired_unit, str) or isinstance(desired_unit, sc.Unit)
    ):
        raise TypeError('`desired_unit` must be a string representing a valid unit.')
    self._desired_unit = desired_unit
    # List of allowed python constructs for the asteval interpreter
    asteval_config = {
        'import': False,
        'importfrom': False,
        'assert': False,
        'augassign': False,
        'delete': False,
        'if': True,
        'ifexp': True,
        'for': False,
        'formattedvalue': False,
        'functiondef': False,
        'print': False,
        'raise': False,
        'listcomp': False,
        'dictcomp': False,
        'setcomp': False,
        'try': False,
        'while': False,
        'with': False,
    }
    self._dependency_interpreter = Interpreter(config=asteval_config)

    # Process the dependency expression for unique names
    try:
        self._process_dependency_unique_names(self._dependency_string)
    except ValueError as error:
        self._revert_dependency(skip_detach=True)
        raise error

    for key, value in self._dependency_map.items():
        self._dependency_interpreter.symtable[key] = value
        self._dependency_interpreter.readonly_symbols.add(
            key
        )  # Dont allow overwriting of the dependencies in the dependency expression  # noqa: E501
        value._attach_observer(self)
    # Check the dependency expression for errors
    try:
        dependency_result = self._dependency_interpreter.eval(
            self._clean_dependency_string, raise_errors=True
        )
    except NameError as message:
        self._revert_dependency()
        raise NameError(
            '\nUnknown name encountered in dependecy expression:'
            + '\n'
            + '\n'.join(str(message).split('\n')[1:])
            + '\nPlease check your expression or add the name to the `dependency_map`'
        ) from None
    except Exception as message:
        self._revert_dependency()
        raise SyntaxError(
            '\nError encountered in dependecy expression:'
            + '\n'
            + '\n'.join(str(message).split('\n')[1:])
            + '\nPlease check your expression'
        ) from None
    if not isinstance(dependency_result, DescriptorNumber):
        error_string = self._dependency_string
        self._revert_dependency()
        raise TypeError(
            f'The dependency expression: "{error_string}" returned a {type(dependency_result)},'
            'it should return a Parameter or DescriptorNumber.'
        )  # noqa: E501
    # Check for cyclic dependencies
    try:
        self._validate_dependencies()
    except RuntimeError as error:
        self._revert_dependency()
        raise error
    # Update the parameter with the dependency result
    self._fixed = False

    if self._desired_unit is not None:
        try:
            dependency_result._convert_unit(self._desired_unit)
        except Exception as e:
            desired_unit_for_error_message = self._desired_unit
            self._revert_dependency()  # also deletes self._desired_unit
            raise UnitError(
                f'Failed to convert unit from {dependency_result.unit} to {desired_unit_for_error_message}: {e}'
            )

    self._update()

make_independent()

Make this parameter independent. This will remove the dependency expression, the dependency map and the dependency interpreter.

:return: None

Source code in src/easyscience/variable/parameter.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
def make_independent(self) -> None:
    """Make this parameter independent. This will remove the
    dependency expression, the dependency map and the dependency
    interpreter.

    :return: None
    """
    if not self._independent:
        for dependency in self._dependency_map.values():
            dependency._detach_observer(self)
        self._independent = True
        del self._dependency_map
        del self._dependency_interpreter
        del self._dependency_string
        del self._clean_dependency_string
        del self._desired_unit
    else:
        raise AttributeError('This parameter is already independent.')

max property writable

Get the maximum value for fitting.

:return: maximum value

min property writable

Get the minimum value for fitting.

:return: minimum value

resolve_pending_dependencies()

Resolve pending dependencies after deserialization.

This method should be called after all parameters have been deserialized to establish dependency relationships using serializer_ids.

Source code in src/easyscience/variable/parameter.py
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
def resolve_pending_dependencies(self) -> None:
    """Resolve pending dependencies after deserialization.

    This method should be called after all parameters have been
    deserialized to establish dependency relationships using
    serializer_ids.
    """
    if hasattr(self, '_pending_dependency_string'):
        dependency_string = self._pending_dependency_string
        dependency_map = {}

        if hasattr(self, '_pending_dependency_map_serializer_ids'):
            dependency_map_serializer_ids = self._pending_dependency_map_serializer_ids

            # Build dependency_map by looking up objects by serializer_id
            for key, serializer_id in dependency_map_serializer_ids.items():
                dep_obj = self._find_parameter_by_serializer_id(serializer_id)
                if dep_obj is not None:
                    dependency_map[key] = dep_obj
                else:
                    raise ValueError(
                        f"Cannot find parameter with serializer_id '{serializer_id}'"
                    )

        # Establish the dependency relationship
        try:
            self.make_dependent_on(
                dependency_expression=dependency_string,
                dependency_map=dependency_map,
                desired_unit=self._pending_desired_unit,
            )
        except Exception as e:
            raise ValueError(f"Error establishing dependency '{dependency_string}': {e}")

        # Clean up temporary attributes
        delattr(self, '_pending_dependency_string')
        delattr(self, '_pending_dependency_map_serializer_ids')
        delattr(self, '_pending_desired_unit')

set_desired_unit(unit_str)

Set the desired unit for a dependent Parameter. This will convert the parameter to the desired unit.

:param unit_str: The desired unit as a string.

Source code in src/easyscience/variable/parameter.py
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
def set_desired_unit(self, unit_str: str | sc.Unit | None) -> None:
    """Set the desired unit for a dependent Parameter. This will
    convert the parameter to the desired unit.

    :param unit_str: The desired unit as a string.
    """

    if self._independent:
        raise AttributeError(
            'This is an independent parameter, desired unit can only be set for dependent parameters.'
        )
    if not (isinstance(unit_str, str) or isinstance(unit_str, sc.Unit) or unit_str is None):
        raise TypeError('`unit_str` must be a string representing a valid unit.')

    if unit_str is not None:
        try:
            old_unit_for_message = self.unit
            self._convert_unit(unit_str)
        except Exception as e:
            raise UnitError(
                f'Failed to convert unit from {old_unit_for_message} to {unit_str}: {e}'
            )

    self._desired_unit = unit_str
    self._update()

value property writable

Get the value of self as a Number.

:return: Value of self without unit.

value_no_call_back property

Get the currently hold value of self suppressing call back.

:return: Value of self without unit.

variance(variance_float)

Set the variance.

:param variance_float: Variance as a float

Source code in src/easyscience/variable/parameter.py
483
484
485
486
487
488
489
490
491
492
493
494
@DescriptorNumber.variance.setter
def variance(self, variance_float: float) -> None:
    """Set the variance.

    :param variance_float: Variance as a float
    """
    if self._independent:
        DescriptorNumber.variance.fset(self, variance_float)
    else:
        raise AttributeError(
            'This is a dependent parameter, its variance cannot be set directly.'
        )

parameter_dependency_resolver

deserialize_and_resolve_parameters(params_data)

Deserialize parameters from a dictionary and resolve their dependencies.

This is a convenience function that combines Parameter.from_dict() deserialization with dependency resolution in a single call.

:param params_data: Dictionary mapping parameter names to their serialized data :return: Dictionary mapping parameter names to deserialized Parameters with resolved dependencies

Source code in src/easyscience/variable/parameter_dependency_resolver.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def deserialize_and_resolve_parameters(
    params_data: Dict[str, Dict[str, Any]],
) -> Dict[str, Parameter]:
    """Deserialize parameters from a dictionary and resolve their
    dependencies.

    This is a convenience function that combines Parameter.from_dict()
    deserialization with dependency resolution in a single call.

    :param params_data: Dictionary mapping parameter names to their
        serialized data
    :return: Dictionary mapping parameter names to deserialized
        Parameters with resolved dependencies
    """
    # Deserialize all parameters first
    new_params = {}
    for name, data in params_data.items():
        new_params[name] = Parameter.from_dict(data)

    # Resolve all dependencies
    resolve_all_parameter_dependencies(new_params)

    return new_params

get_parameters_with_pending_dependencies(obj)

Find all Parameter objects in an object hierarchy that have pending dependencies.

:param obj: The object to search for Parameters :return: List of Parameters with pending dependencies

Source code in src/easyscience/variable/parameter_dependency_resolver.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def get_parameters_with_pending_dependencies(obj: Any) -> List[Parameter]:
    """Find all Parameter objects in an object hierarchy that have
    pending dependencies.

    :param obj: The object to search for Parameters
    :return: List of Parameters with pending dependencies
    """
    parameters_with_pending = []

    def _collect_pending_parameters(item: Any) -> None:
        """Recursively collect all Parameter objects with pending
        dependencies.
        """
        if isinstance(item, Parameter):
            if hasattr(item, '_pending_dependency_string'):
                parameters_with_pending.append(item)
        elif isinstance(item, dict):
            for value in item.values():
                _collect_pending_parameters(value)
        elif isinstance(item, (list, tuple)):
            for element in item:
                _collect_pending_parameters(element)
        elif hasattr(item, '__dict__'):
            # Check instance attributes
            for attr_name, attr_value in item.__dict__.items():
                if not attr_name.startswith('_'):  # Skip private attributes
                    _collect_pending_parameters(attr_value)

            # Check class properties (descriptors like Parameter instances)
            for attr_name in dir(type(item)):
                if not attr_name.startswith('_'):  # Skip private attributes
                    class_attr = getattr(type(item), attr_name, None)
                    if isinstance(class_attr, property):
                        try:
                            attr_value = getattr(item, attr_name)
                            _collect_pending_parameters(attr_value)
                        except (AttributeError, Exception):
                            # log the exception
                            print(f"Error accessing property '{attr_name}' of {item}")
                            # Skip properties that can't be accessed
                            continue

    _collect_pending_parameters(obj)
    return parameters_with_pending

resolve_all_parameter_dependencies(obj)

Recursively find all Parameter objects in an object hierarchy and resolve their pending dependencies.

This function should be called after deserializing a complex object that contains Parameters with dependencies to ensure all dependency relationships are properly established.

:param obj: The object to search for Parameters (can be a single Parameter, list, dict, or complex object)

Source code in src/easyscience/variable/parameter_dependency_resolver.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def resolve_all_parameter_dependencies(obj: Any) -> None:
    """Recursively find all Parameter objects in an object hierarchy and
    resolve their pending dependencies.

    This function should be called after deserializing a complex object
    that contains Parameters with dependencies to ensure all dependency
    relationships are properly established.

    :param obj: The object to search for Parameters (can be a single
        Parameter, list, dict, or complex object)
    """

    def _collect_parameters(item: Any, parameters: List[Parameter]) -> None:
        """Recursively collect all Parameter objects from an item."""
        if isinstance(item, Parameter):
            parameters.append(item)
        elif isinstance(item, dict):
            for value in item.values():
                _collect_parameters(value, parameters)
        elif isinstance(item, (list, tuple)):
            for element in item:
                _collect_parameters(element, parameters)
        elif hasattr(item, '__dict__'):
            # Check instance attributes
            for attr_name, attr_value in item.__dict__.items():
                if not attr_name.startswith('_'):  # Skip private attributes
                    _collect_parameters(attr_value, parameters)

            # Check class properties (descriptors like Parameter instances)
            for attr_name in dir(type(item)):
                if not attr_name.startswith('_'):  # Skip private attributes
                    class_attr = getattr(type(item), attr_name, None)
                    if isinstance(class_attr, property):
                        try:
                            attr_value = getattr(item, attr_name)
                            _collect_parameters(attr_value, parameters)
                        except (AttributeError, Exception):
                            # log the exception
                            print(f"Error accessing property '{attr_name}' of {item}")
                            # Skip properties that can't be accessed
                            continue

    # Collect all parameters
    all_parameters = []
    _collect_parameters(obj, all_parameters)

    # Resolve dependencies for all parameters that have pending dependencies
    resolved_count = 0
    error_count = 0
    errors = []

    for param in all_parameters:
        if hasattr(param, '_pending_dependency_string'):
            try:
                param.resolve_pending_dependencies()
                resolved_count += 1
            except Exception as e:
                error_count += 1
                serializer_id = getattr(param, '_DescriptorNumber__serializer_id', 'unknown')
                errors.append(
                    f"Failed to resolve dependencies for parameter '{param.name}'"
                    f" (unique_name: '{param.unique_name}', serializer_id: '{serializer_id}'): {e}"
                )

    # Report results
    if resolved_count > 0:
        print(f'Successfully resolved dependencies for {resolved_count} parameter(s).')

    if error_count > 0:
        error_message = (
            f'Failed to resolve dependencies for {error_count} parameter(s):\n' + '\n'.join(errors)
        )
        raise ValueError(error_message)