-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathrange.py
More file actions
206 lines (189 loc) · 9.99 KB
/
range.py
File metadata and controls
206 lines (189 loc) · 9.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
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
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
import logging
from enum import Enum
from typing import Any, Never, Sequence
from component_model.unit import Unit
logger = logging.getLogger(__name__)
class Range(object):
"""Utility class to store and handle the variable range of a single-valued variable.
Args:
val: value for which the range is defined.
At least an example value of the same type in base units shall be provided.
rng (tuple) = (): Optional range of the variable in terms of a tuple of the same type as initial value.
Should be specified with units (as string) and is expected in display units.
* If an empty tuple is specified, the range is automatically determined.
That is only possible for float or enum type variables, where the former evaluates to (-inf, inf).
Maximum or minimum int values do not exist in Python, such that these always must be provided explicitly.
It is not possible to set only one of the elements of the tuple automatically.
* If None is specified, the initial value is chosen, i.e. no range.
`None` can be applied to the whole tuple or to single elements of the tuple.
E.g. (1,None) sets the range to (1, start)
* For some variable types (e.g. str) no range is expected.
* Internally, the range is stored in base units.
For range checking of a new value, the new value must be converted to base units before check.
unit (Unit): expected Unit (should be determined for start value before range is determined)
"""
def __init__(
self,
val: bool | int | float | str | Enum,
rng: tuple[Any, Any] | None | Sequence[Never] = tuple(),
unit: Unit | None = None,
):
self.rng: tuple[int | bool | float | str, int | bool | float | str]
typ = type(val)
if unit is None:
unit = Unit()
assert isinstance(val, (bool, int, float, str, Enum)), f"Only primitive types allowed for Range. Found {typ}"
if isinstance(val, str):
assert unit.u == "", f"A free string cannot have units. Found {unit.u}"
self.rng = (val, val) # no range for free strings
elif rng is None: # fixed value in any case. val provided in base units. No conversion
self.rng = (val, val) # type: ignore[assignment] ## see def above
elif isinstance(rng, tuple) and not len(rng): # empty tuple => try automatic range
self.rng = Range.auto_extreme(val) # fails if val is an int variable
elif (
isinstance(rng, tuple)
and len(rng) == 2
and all(x is None or isinstance(x, (str, int, bool, float, Enum)) for x in rng)
):
l_rng = list(rng) # work on a mutable object
for i, r in enumerate(rng):
if r is None: # fixed value on this side. val provided in base units.
l_rng[i] = val # type: ignore[reportArgumentType] ## l_rng is not empty # fixed display value
else:
assert isinstance(r, (str, int, bool, float, Enum)), f"Found type {type(r)}"
check, q = unit.compatible(r, no_unit=False, strict=False) # q in base units
if not check:
raise ValueError(f"Provided range {rng}[{i}] is not conformant with unit {unit}") from None
assert isinstance(q, (int, bool, float)), "Unexpected type {type(q)} in {rng}[{i}]"
try:
q = type(val)(q) # ensure correct Python type
except Exception as err:
raise TypeError(f"Incompatible types range {rng} - {val}") from err
l_rng[i] = q # type: ignore[reportArgumentType] ## l_rng is not empty
self.rng = tuple(l_rng) # type: ignore ## cannot see how tuple contains str or None here!
else:
raise TypeError(f"Unhandled range specification {rng}) from None")
@classmethod
def auto_extreme(cls, var: bool | int | float | str | Enum | type) -> tuple[int | float | bool, int | float | bool]:
"""Return the extreme values of the variable.
Args:
var: the variable for which to determine the extremes,
represented by an instantiated object (example) or by the type itself
Returns
-------
A tuple containing the minimum and maximum value the given variable can have
"""
if isinstance(var, bool) or (isinstance(var, type) and var is bool):
return (False, True)
elif isinstance(var, float) or (isinstance(var, type) and var is float):
return (float("-inf"), float("inf"))
elif isinstance(var, Enum) or (isinstance(var, type) and issubclass(var, Enum)):
if isinstance(var, Enum):
return (min(x.value for x in type(var)), max(x.value for x in type(var)))
else:
return (min(x.value for x in var), max(x.value for x in var))
else:
if isinstance(var, type):
raise ValueError(f"Auto-extremes for type {var} cannot be determined") from None
else:
raise ValueError(f"Auto-extremes for type {type(var)} cannot be determined") from None
def check(
self,
value: bool | int | float | str | Enum | None,
typ: type = float,
unit: Unit | None = None,
disp: bool = True,
) -> bool:
"""Check a value with respect to type and range.
Args:
value: the Python value to check with respect to the internally defined Range
typ (type): the expected Python type of the value
unit (Unit): the Unit object related to the variable
disp (bool): denotes whether 'value' is in display units (True) or base units (False)
"""
if unit is None:
unit = Unit()
if value is None: # denotes unchanged values (of compound variables)
return True
if not isinstance(value, typ):
if issubclass(typ, Enum):
if isinstance(value, Enum):
value = value.value
assert isinstance(value, int) and isinstance(self.rng[0], int) and isinstance(self.rng[0], int), (
f"Enum range is managed as int. Found {self.rng}, {value}"
)
return self.rng[0] <= value <= self.rng[1] # type: ignore[operator] ## all arguments int!
else:
try:
assert typ is not None, "Need a proper typ argument here"
value = typ(value) # type: ignore ## try to cast the values
except Exception: # give up
return False
# special types
if typ is str: # no range checking on str
return True
elif typ is bool:
return isinstance(value, bool)
elif isinstance(value, Enum):
return self.rng[0] <= value.value <= self.rng[1] # type: ignore[operator] ## There is no str involved!
elif isinstance(value, (int, float)) and all(isinstance(x, (int, float)) for x in self.rng):
# assert typ is int or typ is float, f"Inconsistent type {typ} for value {value}. Expected int or float"
if disp and unit.to_base is not None: # check a display unit values
value = unit.to_base(value)
return self.rng[0] <= value <= self.rng[1] # type: ignore[operator] ## There is no str involved!
else:
logger.error(f"range check(): value={value}, type={typ}, range={self.rng}")
return False
@classmethod
def is_valid_spec(
cls, rng: tuple[Any, ...] | tuple[Any, Any] | None | Sequence[Never], var_len: int, typ: type, level: int = 0
) -> int:
"""Check whether the supplied rng is a valid range specification for a variable.
Applies to scalar and compound variable specs.
Return 0 (ok) or error code >0 if not ok.
"""
if rng is None:
ck = 0 # fixed value(s)
elif isinstance(rng, tuple) and not len(rng): # all automatic
ck = int(typ is int) # 1/0 (not possible for int)
elif isinstance(rng, tuple): # need a tuple now
if var_len == 1:
if len(rng) != 2: # scalar specified by a 2-tuple
ck = 2
else: # final check of scalar spec 2-tuple
ck = 0
for i, r in enumerate(rng):
if r is not None and not isinstance(r, (int, bool, float, Enum, str)):
ck += 10 + i
if rng[i] is not None and not any(isinstance(rng[i], str) for i in range(2)):
if rng[0] > rng[1]: # wrong order
ck += 10 + 9
elif var_len > 1:
if len(rng) != var_len: # one range for each variable
ck = 3
else:
ck = 0
for i, r in enumerate(rng):
ck += Range.is_valid_spec(r, 1, typ, level=(i + 1) * 100)
else:
ck = 4 # would need a tuple here
return ck if ck == 0 else level + ck
@classmethod
def err_code_msg(cls, code: int) -> str:
if code == 0:
return "Ok"
elif code == 1:
return "Automatic range for int variables is not defined."
elif code == 2:
return "Full range specification of scalar expects a 2-tuple"
elif code == 3:
return "Range specification of compound variable expects one spec per sub-variable."
elif 10 <= code < 19:
return "Wrong entry in full range specification of scalar."
elif code == 19:
return "Wrong order of entries in full range specification of scalar"
elif code > 100:
sub = Range.err_code_msg(code % 100)
return f"Error in compound variable: {sub}"
else:
return f"Unknown error code {code}"