# -*- coding: utf-8 -*-
"""
Created on Sat Dec 29 08:27:01 2018
@author: yoelr
"""
from ._unit_registry import Quantity
from ._unit_manager import UnitManager
from warnings import warn
# %% Representation
def dim(string):
"""Return string with gray ansicolor coding."""
return '\x1b[37m\x1b[22m' + string + '\x1b[0m'
def _as_literal_dict(self):
literal_dict = {}
units = self._units
for k, v in self.items():
if isinstance(v, SmartBook):
literal_dict[k] = _as_literal_dict(v)
else:
if k in units:
if isinstance(v, self.Quantity):
v.ito(units[k])
v = v.magnitude
units = ' ' + units[k]
else:
units = dim(' ' + units[k])
try:
literal_dict[k] = Literal(f"{v:.3g}{units}")
except: # Values may not have g-format
literal_dict[k] = Literal(f"{repr(v)}{units}")
else:
literal_dict[k] = v
return literal_dict
def _info(self, previous_dicts=[], N_recursive=0, depth=2):
"""Return representation of self recursively.
**Parameters**
**previous_dicts:** List of dictionaries that self is nested in.
**N_recursive:** Current number of SmartBook recursions.
**depth:** Maximum allowable recursions.
"""
if depth < 0:
raise ValueError(f'depth must be 0 or higher, not {depth}.')
udict = self._units
out = ''
N_spaces = 4*(N_recursive)
if N_recursive == 0:
new_line = ''
elif N_recursive < depth+1:
new_line = '\n' + N_spaces*' '
else:
return f'<{type(self).__name__}>,\n '
N_recursive += 1
if self:
# Log self to prevent infinite recursion later
previous_dicts.append(self)
# Add lines of key-value pairs
out += new_line + '{'
for key, value in self.items():
units = udict.get(key, '')
out += _info_item(self, key, value, units, previous_dicts, N_recursive, depth)
out = out[:-(4*N_recursive - 1)] + '},\n '
# Log out self
del previous_dicts[-1]
else:
out += new_line + '{},\n '
if N_recursive == 1:
return out[:-4] + '}'
else:
return out
def _info_item(self, key, value, units, previous_dicts, N_recursive, depth):
"""Represent key-value pair recursively."""
Q_ = self.Quantity
out = f"'{key}': "
if isinstance(value, dict):
if value in previous_dicts:
# Prevent infinite recursion
out += "{...},\n "
elif value and isinstance(value, SmartBook) and len(value) > 1:
# Pretty representation of inner smart book
out += _info(value, previous_dicts, N_recursive, depth)
else:
# Normal representation of dictionaries
out += repr(value) + ',\n '
else:
if units:
if isinstance(value, Q_):
value.ito(units)
value = value.magnitude
units = ' ' + units
else:
units = dim(' ' + units)
try:
out += f"{value:.3g}{units},\n "
except: # Values may not have g-format
out += f"{repr(value)}{units},\n "
# Include spaces for next line
out += 4*(N_recursive - 1)*' '
return out
class Literal(str):
def __repr__(self): return self
# %% SmartBook
class BookkeepWarning(Warning):
"""Warning for bookkeeping."""
[docs]class SmartBook(dict):
"""Create a dictionary that represents values with units of measure and alerts when setting an item out of bounds. Bounds are always inclusive.
**Parameters**
**units:** [UnitManager or dict] Dictionary of units of measure.
**bounds:** [dict] Dictionary of bounds.
***args:** Key-value pairs to initialize.
**source:** [str] Should be one of the following [-]:
* Short description of the smartbook.
* Object which the smartbook belongs to.
* None
****kwargs:** Key-value pairs to initialize.
**Class Attribute**
**Quantity:** `pint Quantity <https://pint.readthedocs.io/en/latest/>`__ class for compatibility.
**Examples**
SmartBook objects provide an easy way to keep track of units of measure and enforce bounds.
Create a SmartBook object with *units*, *bounds*, a *source* description, and *arguments* to initialize the dictionary:
>>> from bookkeep import SmartBook
>>> sb = SmartBook(units={'T': 'K', 'Duty': 'kJ/hr'},
... bounds={'T': (0, 1000)},
... source='Operating conditions',
... T=350)
>>> sb
{'T': 350 (K)}
The *units* attribute becomes a :doc:`UnitManager` object with a reference to all dictionaries (*clients*) it controls. These include the SmartBook and its bounds.
>>> sb.units
UnitManager:
{'T': 'K',
'Duty': 'kJ/hr'}
>>> sb.units.clients
[{'T': 350 (K)}, {'T': (0, 1000)}]
Change units:
>>> sb.units['T'] = 'degC'
>>> sb
{'T': 76.85 (degC)}
>>> sb.bounds
{'T': (-273.15, 726.85)}
Add items:
>>> sb['P'] = 101325
>>> sb
{'T': 76.85 (degC),
'P': 101325}
Add units:
>>> sb.units['P'] = 'Pa'
>>> sb
{'T': 76.85 (degC),
'P': 101325 (Pa)}
A BookkeepWarning is issued when a value is set out of bounds:
>>> sb['T'] = -300
__main__:1: BookkeepWarning: @Operating conditions: T (-300 degC) is out of bounds (-273.15 to 726.85 degC).
Nested SmartBook objects are easy to read, and can be made using the same units and bounds.
Create new SmartBook objects:
>>> sb1 = SmartBook(sb.units, sb.bounds,
... T=25, P=500)
>>> sb2 = SmartBook(sb.units, sb.bounds,
... T=50, Duty=50)
>>> sb1
{'T': 25 (degC),
'P': 500 (Pa)}
>>> sb2
{'T': 50 (degC),
'Duty': 50 (kJ/hr)})
Create a nested SmartBook object:
>>> nsb = SmartBook(units=sb.units, sb1=sb1, sb2=sb2)
>>> nsb
{'sb1':
{'T': 25 (degC),
'P': 500 (Pa)},
'sb2':
{'T': 50 (degC),
'Duty': 50 (kg/hr)}}
`pint Quantity <https://pint.readthedocs.io/en/latest/>`__ objects are also compatible, so long as the corresponding Quantity class is set as the Quantity attribute.
Set a Quantity object:
>>> Q_ = SmartBook.Quantity
>>> sb1.bounds['T'] = Q_((0, 1000), 'K')
>>> sb1['T'] = Q_(100, 'K')
>>> sb1
{'T': -173.15 degC,
'P': 500 (Pa)}
Setting a Quantity object out of bounds will issue a warning:
>>> sb1['T'] = Q_(-1, 'K')
__main__:1: BookkeepWarning: T (-274.15 degC) is out of bounds (-273.15 to 726.85 degC).
Trying to set a Quantity object with wrong dimensions will raise an error:
>>> Q_ = SmartBook.Quantity
>>> sb1['T'] = Q_(100, 'meter')
DimensionalityError: Cannot convert from 'meter' ([length]) to 'degC' ([temperature])
"""
__slots__ = ('_source' ,'_units', '_bounds')
Quantity = Quantity
def __init__(self, units={}, bounds={}, *args,
source=None, **kwargs):
# Make sure units is a UnitManager and add clients
if isinstance(units, UnitManager):
clients = units.clients
clients.append(self)
else:
units = UnitManager([self], **units)
clients = units.clients
do_not_append = False
for i in clients:
if i is bounds:
do_not_append = True
break
if not do_not_append:
clients.append(bounds)
# Set all attributes and items
self._units = units
self._bounds = bounds
self._source = source
super().__init__(*args, **kwargs)
# Check values
for key, value in self.items():
self.unitscheck(key, value)
self.boundscheck(key, value)
def __setitem__(self, key, value):
self.unitscheck(key, value)
self.boundscheck(key, value)
dict.__setitem__(self, key, value)
@classmethod
def _checkbounds(cls, key, value, units, bounds,
stacklevel, source):
"""A lower level, functional version of "boundscheck". Return True if value within bounds. Return False if value is out of bounds and issue a warning.
**Parameters**
**key:** [str] Name of value
**value:** [number, Quantity, or array]
**units:** [str] Units of measure
**bounds:** [array or Quantity-array] Lower and upper bounds
**stacklevel:** [int] Stacklevel for warning.
**source:** [str or object] Short description or object it describes.
**Example**
>>> SmartBook._checkbounds('Temperature', -1, 'Kelvin', (0, 1000), 2, 'Thermocouple')
False
"""
# Bounds are inclusive
lb, ub = bounds
try: within_bounds = lb<=value and ub>=value
# Handle exception when value or bounds is a Quantity object but the other is not
except ValueError as VE:
Q_ = cls.Quantity
value_is_Q = isinstance(value, Q_)
bounds_is_Q = isinstance(bounds, Q_)
if value_is_Q and not bounds_is_Q:
value.ito(units)
value = value.magnitude
is_Q = 'value'
not_Q = 'bounds'
elif bounds_is_Q and not value_is_Q:
bounds.ito(units)
bounds = bounds.magnitude
is_Q = 'bounds'
not_Q = 'value'
else: raise VE
# Warn to prevent bad usage of SmartBook
name = "'" + key + "'" if isinstance(key, str) else key
msg = f"For key, {name}, {is_Q} is a Quantity object, while {not_Q} is not."
warn(cls._warning(source, msg, BookkeepWarning),
stacklevel=stacklevel)
# Try again recursively
return cls._checkbounds(key, value, units, bounds,
stacklevel+1, source)
# Warn when value is out of bounds
if not within_bounds:
units = ' ' + units
if isinstance(value, cls.Quantity):
value = value.magnitude
if isinstance(bounds, cls.Quantity):
lb = lb.magnitude
ub = ub.magnitude
try:
msg = f"{key} ({value:.4g}{units}) is out of bounds ({lb:.4g} to {ub:.4g}{units})."
except: # Handle format errors
msg = f"{key} ({value:.4g}{units}) is out of bounds ({lb} to {ub} {units})."
warn(cls._warning(source, msg, BookkeepWarning),
stacklevel=stacklevel)
return within_bounds
[docs] def unitscheck(self, key, value):
"""Adjust Quantity objects to correct units and return True."""
if isinstance(value, self.Quantity):
units = self._units.get(key, '')
value.ito(units)
return True
[docs] def boundscheck(self, key, value):
"""Return True if value is within bounds. Return False if value is out of bounds and issue a warning.
**Parameters**
**key:** [str] Name of value
**value:** [number, Quantity, or array]
"""
bounds = self._bounds.get(key)
stacklevel = 4
source = self._source
units = self._units.get(key, '')
if bounds is None:
within_bounds = True
else:
within_bounds = self._checkbounds(key, value, units, bounds,
stacklevel, source)
return within_bounds
[docs] @classmethod
def enforce_boundscheck(cls, val):
"""If `val` is True, issue BookkeepWarning whenever an item is set out of bounds. If *val* is False, ignore bounds."""
if val: cls.boundscheck = cls._boundscheck
else: cls.boundscheck = cls._boundsignore
[docs] @classmethod
def enforce_unitscheck(cls, val):
"""If `val` is True, adjust Quantity objects to correct units. If *val* is False, ignore units."""
if val: cls.unitscheck = cls._unitscheck
else: cls.unitscheck = cls._unitsignore
_boundscheck = boundscheck
_unitscheck = unitscheck
def _boundsignore(*args, **kwargs): pass
def _unitsignore(*args, **kwargs): pass
@property
def units(self):
"""Dictionary of units of measure."""
return self._units
@property
def bounds(self):
"""Dictionary of bounds."""
return self._bounds
@property
def source(self):
"""Short description or object it describes"""
return self._source
[docs] def nested_keys(self):
"""Return all keys of self and nested SmartBook objects."""
yield from self._nested_keys([self], isinstance)
def _nested_keys(self, prev, inst):
for key, val in self.items():
if inst(val, SmartBook) and not val in prev:
if val:
prev.append(val)
yield from val._nested_keys(prev, inst)
prev.remove(val)
else: yield key
[docs] def nested_values(self):
"""Return all values of self and nested SmartBook objects."""
yield from self._nested_values([self], isinstance)
def _nested_values(self, prev, inst):
for key, val in self.items():
if inst(val, SmartBook) and not val in prev:
if val:
prev.append(val)
yield from val._nested_values(prev, inst)
prev.remove(val)
else: yield val
[docs] def nested_items(self):
"""Return all key-value pairs of self and nested SmartBook objects."""
yield from self._nested_items([self], isinstance)
def _nested_items(self, prev, inst):
"""Return all key-value pairs of self and nested SmartBook objects."""
for key, val in self.items():
if inst(val, SmartBook) and not val in prev:
if val:
prev.append(val)
yield from val._nested_items(prev, inst)
prev.remove(val)
else: yield key, val
@staticmethod
def _warning(source, msg, category=Warning):
"""Return a Warning object with source description."""
if isinstance(source, str):
msg = f'@{source}: ' + msg
elif source:
msg = f'@{type(source).__name__} {str(source)}: ' + msg
return category(msg)
def __repr__(self):
return repr(_as_literal_dict(self))
def _repr_pretty_(self, p, cycle):
if cycle:
p.text('{...}')
else:
p.pretty(_as_literal_dict(self))
return p
def _ipython_display_(self):
print(_info(self))