Source code for ams.core.model

"""
Module for Model class.
"""

import logging
from collections import OrderedDict
from typing import Iterable

import numpy as np
from andes.core.param import ExtParam
from andes.core.service import BaseService, BackRef
from ams.utils.func import list_flatten

from ams.core.documenter import Documenter
from ams.core.var import Algeb
from ams.core.common import Config

from ams.utils.misc import deprec_get_idx

logger = logging.getLogger(__name__)


# Keys that were injected into every Model.config by older AMS releases
# (ported from andes.core.model but never read by AMS). Users' persisted
# ~/.ams/ams.rc from that era still carry them per-model, so load() will
# restore them unless we purge them here. Grow this set when any other
# per-model config key is retired.
_DEPRECATED_MODEL_CONFIG_KEYS: frozenset = frozenset({
    "allow_adjust",
    "adjust_lower",
    "adjust_upper",
})


[docs] class Model: """ Base class for power system scheduling models. This class is revised from ``andes.core.model.Model``. """
[docs] def __init__(self, system=None, config=None): # --- Model --- self.system = system self.group = 'Undefined' self.algebs = OrderedDict() # internal algebraic variables self.vars_decl_order = OrderedDict() # variable in the order of declaration self.params_ext = OrderedDict() # external parameters self.services = OrderedDict() # service/temporary variables self.services_ref = OrderedDict() # BackRefs self.services_sum = OrderedDict() # VarSums self.config = Config(name=self.class_name) # `config` that can be exported if config is not None: self.config.load(config) # Silently drop keys that were valid in older AMS releases but # have since been retired. Purge from both the attribute view # (``__dict__``) and Config's lazy backing store (``_dict``) # so ``as_dict()`` / ``save_config()`` can never re-persist # them if a future code path populates ``_dict`` before this # loop runs. See ``_DEPRECATED_MODEL_CONFIG_KEYS``. for _deprecated_key in _DEPRECATED_MODEL_CONFIG_KEYS: self.config.__dict__.pop(_deprecated_key, None) self.config._dict.pop(_deprecated_key, None) self.docum = Documenter(self)
def _all_vars(self): """ An OrderedDict of States, ExtStates, Algebs, ExtAlgebs """ return OrderedDict(list(self.algebs.items())) def _check_attribute(self, key, value): """ Check the attribute pair for valid names while instantiating the class. This function assigns `owner` to the model itself, assigns the name and tex_name. """ if isinstance(value, (Algeb, BaseService)): if not value.owner: value.owner = self if not value.name: value.name = key if not value.tex_name: value.tex_name = key if key in self.__dict__: logger.warning(f"{self.class_name}: redefinition of member <{key}>. Likely a modeling error.") def __setattr__(self, key, value): """ Overload the setattr function to register attributes. Parameters ---------- key : str name of the attribute value : [Algeb] value of the attribute """ self._check_attribute(key, value) self._register_attribute(key, value) super(Model, self).__setattr__(key, value) def _register_attribute(self, key, value): """ Register a pair of attributes to the model instance. Called within ``__setattr__``, this is where the magic happens. Subclass attributes are automatically registered based on the variable type. Block attributes will be exported and registered recursively. """ if isinstance(value, Algeb): self.algebs[key] = value elif isinstance(value, ExtParam): self.params_ext[key] = value elif isinstance(value, BackRef): self.services_ref[key] = value self.services[key] = value @property def class_name(self): """ Return the class name """ return self.__class__.__name__
[docs] def list2array(self): """ Convert all the value attributes ``v`` to NumPy arrays. Value attribute arrays should remain in the same address afterwards. Namely, all assignments to value array should be operated in place (e.g., with [:]). """ for instance in self.num_params.values(): instance.to_array()
[docs] def set_backref(self, name, from_idx, to_idx): """ Helper function for setting idx-es to ``BackRef``. """ if name not in self.services_ref: return uid = self.idx2uid(to_idx) self.services_ref[name].v[uid].append(from_idx)
[docs] def get(self, src: str, idx, attr: str = 'v', allow_none=False, default=0.0): """ Get the value of an attribute of a model property. The return value is ``self.<src>.<attr>[idx]`` Parameters ---------- src : str Name of the model property idx : str, int, float, array-like Indices of the devices attr : str, optional, default='v' The attribute of the property to get. ``v`` for values, ``a`` for address, and ``e`` for equation value. allow_none : bool True to allow None values in the indexer default : float If `allow_none` is true, the default value to use for None indexer. Returns ------- array-like ``self.<src>.<attr>[idx]`` """ uid = self.idx2uid(idx) if isinstance(self.__dict__[src].__dict__[attr], list): if isinstance(uid, Iterable): if not allow_none and (uid is None or None in uid): raise KeyError('None not allowed in uid/idx. Enable through ' '`allow_none` and provide a `default` if needed.') return [self.__dict__[src].__dict__[attr][i] if i is not None else default for i in uid] # FIXME: this seems to be an unexpected case originted from ANDES if isinstance(uid, Iterable): if None in uid: return [self.__dict__[src].__dict__[attr][i] if i is not None else default for i in uid] return self.__dict__[src].__dict__[attr][uid]
[docs] def set(self, src, idx, *args, value=None, attr='v', base=None): r""" Set the value of an attribute of a model property. Performs ``self.<src>.<attr>[idx] = value``. This method will not modify the input values from the case file that have not been converted to the system base. As a result, changes applied by this method will not affect the dumped case file. To alter parameters and reflect it in the case file, use :meth:`alter` instead. .. note:: The signature was updated in the ANDES v2.0.0 compatibility shim. ANDES v2.0.0 ``GroupBase.set()`` dispatches as ``mdl.set(src, idx, val, attr=attr, base=base)`` — passing the value positionally and ``attr`` as a keyword argument. The old AMS callers used ``mdl.set(src, idx, attr_str, value)`` — positional ``attr`` followed by positional ``value``. The ``*args`` approach below supports both call patterns. Parameters ---------- src : str Name of the model property idx : str, int, float, array-like Indices of the devices \*args : positional Accepted for compatibility. Two interpretations depending on whether ``attr`` is also passed as a keyword: - ``set(src, idx, value_array)`` — one positional extra arg, ``attr`` keyword selects the sub-attribute (v2.0.0 style). - ``set(src, idx, attr_str, value_array)`` — two positional extra args, first is the attribute name (old AMS caller style). attr : str, optional, default='v' The internal attribute of the property to get. ``v`` for values, ``a`` for address, and ``e`` for equation value. value : array-like, optional New values to be set (keyword form; takes precedence over ``*args``). base : ignored Accepted for API compatibility with ANDES v2.0.0; not used by AMS. Returns ------- bool True when successful. """ del base # API-compat noop — accepted but ignored (see docstring) # Resolve attr and value from the mixed positional / keyword call styles. # # Style A (ANDES v2.0.0 GroupBase dispatch): # set(src, idx, val, attr=attr, base=base) # → args = (val,), attr already set by keyword # # Style B (old AMS callers): # set(src, idx, attr_str, value_array) # → args = (attr_str, value_array), attr keyword still at default 'v' if value is not None: # Explicit keyword `value=` wins unconditionally. resolved_value = value resolved_attr = attr elif len(args) == 2: # Old positional style: (attr_str, value_array) resolved_attr, resolved_value = args elif len(args) == 1: # New ANDES v2.0.0 style: (value_array,) with attr as keyword resolved_value = args[0] resolved_attr = attr else: raise TypeError( f"set() requires a value; got src={src!r}, idx={idx!r}, " f"args={args!r}, value={value!r}" ) uid = self.idx2uid(idx) self.__dict__[src].__dict__[resolved_attr][uid] = resolved_value return True
[docs] def alter(self, src, idx, value, attr='v'): """ Alter values of input parameters or constant service. If the method operates on an input parameter, the new data should be in the same base as that in the input file. This function will convert ``value`` to per unit in the system base whenever necessary. The values for storing the input data, i.e., the parameter's ``vin`` field, will be overwritten. As a result, altered values will be reflected in the dumped case file. Parameters ---------- src : str The parameter name to alter idx : str, float, int The device to alter value : float The desired value attr : str The attribute to alter, default is 'v'. Notes ----- .. versionchanged:: 0.9.14 The ``attr`` argument was added to allow altering specific attributes. This is useful when manipulating parameter values in the system base and ensuring that changes are reflected in the dumped case file. """ instance = self.__dict__[src] if hasattr(instance, 'vin') and (instance.vin is not None): uid = self.idx2uid(idx) if attr == 'vin': self.set(src, idx, 'vin', value / instance.pu_coeff[uid]) self.set(src, idx, 'v', value=value) else: self.set(src, idx, 'vin', value) self.set(src, idx, 'v', value * instance.pu_coeff[uid]) elif not hasattr(instance, 'vin') and attr == 'vin': logger.warning(f"{self.class_name}.{src} has no `vin` attribute, changing `v`.") self.set(src, idx, 'v', value) else: self.set(src, idx, attr=attr, value=value)
[docs] def idx2uid(self, idx): """ Convert idx to the 0-indexed unique index. Parameters ---------- idx : array-like, numbers, or str idx of devices Returns ------- list A list containing the unique indices of the devices """ if idx is None: logger.debug("idx2uid returned None for idx None") return None if isinstance(idx, (float, int, str, np.integer, np.floating)): return self._one_idx2uid(idx) elif isinstance(idx, Iterable): if len(idx) > 0 and isinstance(idx[0], (list, np.ndarray)): idx = list_flatten(idx) return [self._one_idx2uid(i) if i is not None else None for i in idx] else: raise NotImplementedError(f'Unknown idx type {type(idx)}')
def _one_idx2uid(self, idx): """ Helper function for checking if an idx exists and converting it to uid. """ if idx not in self.uid: raise KeyError("<%s>: device not exist with idx=%s." % (self.class_name, idx)) return self.uid[idx]
[docs] def doc(self, max_width=78, export='plain'): """ Retrieve model documentation as a string. """ return self.docum.get(max_width=max_width, export=export)
[docs] @deprec_get_idx def get_idx(self): """ Return the index of the model instance. Equivalent to ``self.idx.v``, develoepd for consistency with group method ``get_idx``. Notes ----- .. deprecated:: 1.0.0 Use ``get_all_idxes`` instead. """ return self.idx.v
[docs] def get_all_idxes(self): """ Return all the indexes of this model. Returns ------- list A list of indexes. Notes ----- .. versionadded:: 1.0.0 """ return self.idx.v
def __repr__(self): dev_text = 'device' if self.n == 1 else 'devices' return f'{self.class_name} ({self.n} {dev_text}) at {hex(id(self))}'