"""Device Library/Parameter For the number or data structure used in this library, we can group them into there category: 1. raw instruction value (P value) The value store in the device parameter and used in the instruction building. #. raw parameter value (V value) The value store in the device configuration which has a physical meaning. It will be saved into the recording meta file if has property *record_meta*. #. human-readable (H value) The value or text just to present to human and used in view module. """ from . import * from .domain import * class PropertySetter(JsonSerialize): """ **json format** :: PropertySetter = "exact" | "near" | "floor" | "ceil" | PropertySetterObject PropertySetterObject = { "variable"? :variable_table "parameter" :parameter_table } variable_table = { # with scope from caller variable_name = Expression[T] } parameter_table = { # with scope from caller plus variable_table parameter_name = Expression[T] } """ CAST_EXACT = "exact" CAST_NEAR = "near" CAST_FLOOR = "floor" CAST_CEIL = "ceil" CAST_NT = "NT" # XXX rename __slots__ = ('_parameter', '_variable') def __init__(self, parameter: Dict[str, Expression[Any]], variable: Dict[str, Expression[Any]] = None): self._parameter = parameter self._variable = variable def cast_value(self, value: Any, context: Scope) -> Dict[str, Any]: """ :param value: V value :param context: general case root scope :return: dict with parameter name map to P value """ scope = self._resolve_variable(value, context) ret = {} for par, exp in self._parameter.items(): ret[par] = exp.value(scope) return ret def _resolve_variable(self, value: Any, context: Scope) -> Scope: scope = context.child(VALUE=value) if self._variable is not None: var_set = set(self._variable.keys()) var_set_size = len(var_set) while var_set_size != 0: for var, exp in self._variable.items(): if var not in var_set: try: val = exp.value(scope) except NameError: pass else: scope.set_local(var, val) var_set.discard(var) if len(var_set) == var_set_size: raise RuntimeError('recursive variable define : ' + ' '.join(var_set)) var_set_size = len(var_set) return scope def as_json(self): ret = { 'parameter': {k: v.as_json() for k, v in self._parameter.items()} } if self._variable is not None: ret['variable'] = {k: v.as_json() for k, v in self._variable.items()} return ret @classmethod def parse(cls, json: JSON_OBJECT) -> 'PropertySetter': par = _get(json, 'parameter', dict, 'value_set') var = _get(json, 'variable', dict, 'value_set', nullable=True) par = _dict(par, lambda k, v: Expression.parse(v)) if var is not None: var = _dict(var, lambda k, v: Expression.parse(v)) return PropertySetter(par, var) @classmethod def find_root(cls, strategy: str, f: Callable[[int], float], v: float, s: Tuple[int, int]) -> Optional[int]: """ :param strategy: strategy method :param f: function (P) -> V :param v: V value :param s: seed range :return: P value, None if fail """ if strategy == cls.CAST_EXACT: return cls.exact_root(f, v, s) elif strategy == cls.CAST_NEAR: return cls.near_root(f, v, s) elif strategy == cls.CAST_FLOOR: return cls.ceil_root(f, v, s) elif strategy == cls.CAST_CEIL: return cls.floor_root(f, v, s) elif strategy == cls.CAST_NT: return cls.nt_root(f, v, s) else: raise RuntimeError('unknown cast strategy : ' + strategy) @staticmethod def find_identify(f: Callable[[int], str], value: str, seed_range: Tuple[int, int]) -> Optional[int]: for p in range(*seed_range): if f(p) == value: return p return None @staticmethod def exact_root(f: Callable[[int], float], value: float, seed_range: Tuple[int, int]) -> Optional[int]: # TODO implement binary search algo for p in range(*seed_range): if f(p) == value: return p return None @staticmethod def near_root(f: Callable[[int], float], value: float, seed_range: Tuple[int, int]) -> Optional[int]: ret = None min_d = None for p in range(*seed_range): v = f(p) d = abs(value - v) if min_d is None or d < min_d: ret = p min_d = d return ret @staticmethod def ceil_root(f: Callable[[int], float], value: float, seed_range: Tuple[int, int]) -> Optional[int]: ret = None for p in range(seed_range[1], seed_range[0], -1): if value < f(p): ret = p return ret @staticmethod def floor_root(f: Callable[[int], float], value: float, seed_range: Tuple[int, int]) -> Optional[int]: ret = None for p in range(*seed_range): if f(p) < value: ret = p return ret @staticmethod def nt_root(f: Callable[[int], float], value: float, seed: Tuple[int, int] = None) -> int: if seed is None: s = (0, 100) else: s = (seed[0], seed[1]) v = [f(s[0]), f(s[1])] k = (v[1] - v[0]) / (s[1] - s[0]) a = (s[1] - s[0]) // 2 while True: b = f(a) c = value - b if c == 0: return a d = int(c / k) if d == 0: return a - 1 a += d class ParameterInfo(JsonSerialize): """ **json format** :: ParameterInfo = { "alias"? :str | list[str] "description"? :str "record_meta"? :bool "private"? :bool "domain"? :ParameterDomain "initial"? :int|Expression[T] "guard"? :GuardExpression|ListGuardExpression "value"? :Expression[T] "value_set"? :PropertySetter "on_change"? :str = instruction_name } *record_meta* this parameter will be present in the device configuration by default and will be record in the recording meta file. Required parameters are always *record_meta*. *private* this parameter will not present in the device configuration. *record_meta* parameter cannot be *private*. *domain* parameter P value domain. Either *domain* or *value* should be existed. If only *value* present, *domain* would be considered :class:`ParameterConstantRangeDomain` with value ``[0, len(value)]``. If *domain* is "property", either *value* or *value_set* must present. If *domain* is "action", neither *value* nor *value_set* present. If parameter is ``CHANNEL``, the *domain* only allow "property" or set domain. *guard* parameter guarder when parameter setting and modifying, the property *raise* can be ignored. *value* V value getter, *value_set* V value setter. If *domain* is not a property, it mean the strategy of casting V value to P value. omitted default to "exact". If *domain* is a property, it should be either ``null`` or a :class:`PropertySetter`. *initial* initial P value for this parameter. It can be a expression which will be resolved at loading time with constant scope. If *domain* is :class:`ParameterCollectionDomain`, *initial* will be ignored. **variable scope search chain** * used when initialize 1. constant * used when value evaluation 1. general case root (:class:`biopro.devlib.library.DeviceLibrary`) """ SAMPLE_RATE = 'SAMPLE_RATE' """required parameter with type int""" CHANNEL = 'CHANNEL' """required parameter with type Set[int]""" AMP_GAIN = 'AMP_GAIN' """required parameter with type int""" REQUIRED = (SAMPLE_RATE, CHANNEL, AMP_GAIN) """required parameters""" __slots__ = ('_name', '_domain', '_initial', '_value', '_value_set', '_guard', '_on_change', '_alias', '_description', '_record_meta', '_private') def __init__(self, name: str, domain: ParameterDomain, initial: Optional[Any] = None, guard: Optional[ListGuardExpression] = None, value: Expression[Any] = None, value_set: Union[None, str, PropertySetter] = None, on_change: Optional[str] = None, alias: Union[str, List[str]] = None, description: Optional[str] = None, record_meta=False, private=False): """ :param name: parameter name :param domain: parameter domain :param initial: initial P value :param guard: parameter guarded :param value: parameter present value :param value_set: parameter setter :param on_change: instruction to call when parameter changed :param alias: parameter name alias :param description: parameter description :param record_meta: record this parameter in recording meta file """ if domain is None: raise TypeError('NoneType domain for parameter : ' + name) elif domain == ParameterConstantDomain: if initial is None: initial = 0 elif domain == ParameterPropertyDomain: if value is None and value_set is None: raise RuntimeError('parameter %s with property domain without value/value_set expression' % name) if value_set is not None and not isinstance(value_set, PropertySetter): raise TypeError('value_set of the parameter %s not a PropertySetter' % name) elif domain == ParameterActionDomain: if value is not None or value_set is not None: raise RuntimeError('parameter %s with action domain with value/value_set expression' % name) record_meta = False private = True if name == self.CHANNEL: if domain == ParameterPropertyDomain: pass elif isinstance(domain, ParameterSetDomain): if not isinstance(domain.element_domain, ParameterValueDomain): raise RuntimeError('parameter CHANNEL not a set of value domain') else: raise RuntimeError('parameter CHANNEL not a set or property domain') if isinstance(alias, str): alias = [alias] self._name = name self._domain: ParameterDomain = domain self._initial: Optional[int] = initial self._value: Optional[Expression[Any]] = value self._value_set = value_set self._on_change = on_change self._guard = guard self._alias: List[str] = list(alias) if alias is not None else [] self._description = description # force record meta for required parameter self._record_meta = record_meta or name in self.REQUIRED # record_meta parameter cannot be private self._private = private and not self._record_meta @property def name(self) -> str: """parameter name """ return self._name @property def domain(self) -> ParameterDomain: """parameter domain.""" return self._domain @property def is_readable(self) -> bool: if self._domain == ParameterPropertyDomain and self._value is None: return False elif self._domain == ParameterActionDomain: return False else: return True @property def is_writable(self) -> bool: if self._domain == ParameterConstantDomain: return False elif self._domain == ParameterPropertyDomain and self._value_set is None: return False else: return True @property def element_domain(self) -> ParameterDomain: """parameter domain.""" if isinstance(self._domain, ParameterCollectionDomain): return self._domain.element_domain else: return self._domain @property def description(self) -> Optional[str]: return self._description @property def record_meta(self) -> bool: return self._record_meta @property def private(self) -> bool: return self._private @property def alias(self) -> List[str]: return list(self._alias) @property def on_change(self) -> Optional[str]: return self._on_change def init_para(self) -> Any: """ :return: initial P value :raises value: illegal initial value :raises PropertyDomainError: parameter is a property :raises ActionDomainError: parameter is a action """ if self._domain == ParameterConstantDomain: return int(self._initial) elif self._domain == ParameterPropertyDomain: raise PropertyDomainError(self._name) elif self._domain == ParameterActionDomain: raise ActionDomainError(self._name) else: return self._domain.init_para(self._initial) def ensure_guard(self, context: 'ParameterTableScope'): """guarder ensure value in *context* is validated. :param context: general case root scope :raises IllegalParameterValueError: guarder raise error """ if self._guard is None: return guard = self._guard.test(context) if guard is not None: raise IllegalParameterValueError(self._name, guard.error_message) def get_value(self, value: Any, context: 'ParameterTableScope') -> Any: """get the parameter present value. :param value: P value :param context: general case root scope :return: V value """ if self._domain == ParameterActionDomain: raise ActionDomainError(self._name) if self._domain == ParameterConstantDomain: # overwrite value if constant domain value = int(self._initial) if self._value is None: # parameter no getter, which V value equals to P value. return directly return value else: try: return self._get_value(value, context) except BaseException as e: raise DeviceParameterError(self._name, 'get_value error : ' + str(e)) from e def _get_value(self, value: Any, context: 'ParameterTableScope') -> Any: if isinstance(self._domain, ParameterArrayDomain): if isinstance(value, list): size = self._domain.size index_para = self._domain.index_parameter # keep old index_para value try: index_value_old = context.get_local(index_para) except KeyError: index_value_old = None ret = [] for i in range(size): # print(self._value.as_json(), 'VALUE=' + str(v)) context.set_local(index_para, i) context.set_local('VALUE', value[i]) ret.append(self._value.value(context)) # restore old index_para value if index_value_old is not None: context.set_local(index_para, index_value_old) else: context.del_local(index_para) return ret else: return self._value.value_local(context, VALUE=value) elif isinstance(self._domain, ParameterCollectionDomain): if isinstance(value, (list, set)): ret = [] for v in value: # print(self._value.as_json(), 'VALUE=' + str(v)) ret.append(self._value.value_local(context, VALUE=v)) if isinstance(value, set): return set(ret) else: return ret else: return self._value.value_local(context, VALUE=value) elif isinstance(self._value, ConstantExpression): return self._value.constant elif isinstance(self._value, ArrayExpression): return self._value[int(value)] else: return self._value.value_local(context, VALUE=value) def cast_value(self, value: Any, context: 'ParameterTableScope') -> Any: """get the parameter according to the present value. The parameter not always fit the present value. :param value: V value :param context: general case root scope :return: P value """ if self._domain == ParameterActionDomain: raise ActionDomainError(self._name) setter: PropertySetter = self._value_set if self._domain == ParameterConstantDomain: # constant parameter return int(self._initial) elif isinstance(setter, PropertySetter): # raise an interrupt to indicate caller it is a special operation. raise PropertySetInterrupt(self._name, setter.cast_value(value, context)) elif self._domain == ParameterPropertyDomain: if setter is None: raise PropertyDomainError(self._name, 'not a setter') else: assert not isinstance(setter, PropertySetter) raise TypeError('parameter %s setter not a PropertySetter' % self._name) elif self._value is None: # parameter no getter, which V value equals to P value. just return return value elif isinstance(self._domain, ParameterArrayDomain): domain = self._domain.element_domain size = self._domain.size index_para = self._domain.index_parameter assert index_para in context ret = [] for i in range(size): context.set_local(index_para, i) ret.append(self._cast_value(value[i], domain, context)) return ret elif isinstance(self._domain, ParameterCollectionDomain): domain = self._domain.element_domain ret = [] for v in value: ret.append(self._cast_value(v, domain, context)) if isinstance(value, list): return ret elif isinstance(value, set): return set(ret) else: raise RuntimeError('unknown type for parameter ' + self._name + ' : ' + str(type(value))) else: return self._cast_value(value, self._domain, context) def cast_value_dependency(self) -> List[str]: if self._value is None: return [] elif isinstance(self._value, ComplexExpression): return self._value.dependency else: return [] @staticmethod def _cast_bool(value: Any) -> bool: if value is None: return False elif isinstance(value, bool): return value elif isinstance(value, int): return value > 0 elif isinstance(value, float): return value > 0 elif isinstance(value, str): value = value.lower() return value == '1' or value == 't' or value == 'true' return False def _cast_value(self, value: Any, domain: ParameterDomain, context: Scope) -> Any: if domain == ParameterBoolDomain: return self._cast_bool(value) else: expr = self._value assert expr is not None def _f(_value): return expr.value_local(context, VALUE=_value) strategy = self._value_set if strategy is None: strategy = PropertySetter.CAST_EXACT assert isinstance(strategy, str) ret = None if domain == ParameterIntDomain: seed_range = (int(value / 2), int(value * 3 / 2)) ret = PropertySetter.find_root(PropertySetter.CAST_NT, _f, value, seed_range) elif isinstance(domain, ParameterValueDomain): domain_range = domain.range test = _f(domain_range[0]) if isinstance(test, int): # XXX binary search with [floor, exact, ceil] ret = PropertySetter.find_root(strategy, _f, int(value), domain_range) elif isinstance(test, float): # XXX nt root ret = PropertySetter.find_root(strategy, _f, float(value), domain_range) elif isinstance(test, str): try: value = int(value) if domain_range[0] <= value < domain_range[1]: # return directory return value except ValueError: pass ret = PropertySetter.find_identify(_f, str(value), domain_range) else: raise DeviceParameterError(self._name, 'unsupported cast value for domain : ' + domain.__class__.__name__) if ret is None: raise DeviceParameterError(self._name, 'cannot cast value : ' + str(value)) # print(self.name, ret) return ret def as_json(self): ret = { 'domain': self.domain.as_json(), 'record_meta': self._record_meta, } if len(self._alias) == 1: ret['alias'] = self._alias[0] elif len(self._alias) > 1: ret['alias'] = self._alias if self._description is not None: ret['description'] = self._description if self._initial is not None: ret['initial'] = self._initial if self._value is not None: ret['value'] = self._value.as_json() if self._value_set is not None: ret['value'] = JsonSerialize.to_json(self._value_set) if self._guard is not None: ret['guard'] = self._guard.as_json() return ret class ParameterTable(DictNode[ParameterInfo]): """ **json format** :: ParameterTable = { parameter_name :ParameterInfo } **required parameter** defined in :class:`biopro.devlib.device.DeviceParameter` **variable scope search chain** * used when parameter initializing. 1. "constant" * used when parameter set/add guarding. 1. general case root (:class:`biopro.devlib.library.DeviceLibrary`) #. {VALUE=new value} * used when parameter transform to present value 1. general case root (:class:`biopro.devlib.library.DeviceLibrary`) #. {VALUE=parameter value} relate function :func:`ParameterInfo.get_value`, :func:`ParameterInfo.cast_value` """ __slots__ = () def __init__(self, parameters: Optional[Dict[str, ParameterInfo]] = None): super().__init__(parameters) def keys_all(self, show_private=False) -> Iterable[str]: r = set() for para in self.values(): if show_private or not para.private: r.add(para.name) r.update(para.alias) return r def __contains__(self, key: Any) -> bool: if key in self._dict: return True for para in self.values(): if key in para.alias: return True return False def __missing__(self, key: str) -> ParameterInfo: for para in self.values(): if key in para.alias: return para raise KeyError(key) def get_value(self, name: Union[str, ParameterInfo], value: Any, context: Scope) -> Any: """get parameter V value :param name: parameter name or info :param value: P value :param context: :return: V value :raises KeyError: parameter name not found """ if isinstance(name, str): name = self[name] return name.get_value(value, context.chain(ParameterTableScope(self))) def cast_value(self, name: Union[str, ParameterInfo], value: Any, context: Scope) -> Optional[int]: if isinstance(name, str): name = self[name] return name.cast_value(value, context.chain(ParameterTableScope(self))) def ensure_guard(self, name: Union[str, ParameterInfo], context: Scope): """invoke parameter *name*'s guarder ensure its value in *context* is validated. :param name: parameter or its name :param context: general case root scope :raises IllegalParameterValueError: guarder raise error """ if isinstance(name, str): name = self[name] return name.ensure_guard(context.chain(ParameterTableScope(self))) class ParameterTableScope(Scope): """parameter table scope, make user access the parameter value through the context scope. """ __slots__ = '_table', '_overwrite' def __init__(self, table: ParameterTable): super().__init__() self._table = table self._overwrite = {} def locals(self) -> Iterable[str]: ret = list(self._overwrite.keys()) ret.append('_value') return ret def contain_local(self, key: str) -> bool: return key == '_value' or key in self._overwrite def get_local(self, key: str) -> Any: if key == '_value': return self._value else: return self._overwrite[key] def set_local(self, key: str, value: Any): self._overwrite[key] = value def del_local(self, key: str): del self._overwrite[key] def clear(self): self._overwrite.clear() def _value(self, name: str): para = self._table[name] domain = para.domain ret = para.get_value(self[name], self) # print('_value(%s)' % name, '=', ret) if isinstance(domain, ParameterArrayDomain): ret = domain.get_para(ret, self) return ret