Source code for bdsim.blockdiagram

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Mon May 18 21:43:18 2020

@author: corkep
"""
import os
from pathlib import Path
import sys
import importlib
import inspect
import traceback
from collections import Counter, namedtuple
import numpy as np
from colored import fg, attr


from ansitable import ANSITable, Column

from bdsim.components import *



# ------------------------------------------------------------------------- #    

[docs]class BlockDiagram: """ Block diagram class. This object is the parent of all blocks and wires in the system. :ivar wirelist: all wires in the diagram :vartype wirelist: list of Wire instances :ivar blocklist: all blocks in the diagram :vartype blocklist: list of Block subclass instances :ivar x: state vector :vartype x: np.ndarray :ivar compiled: diagram has successfully compiled :vartype compiled: bool :ivar blockcounter: unique counter for each block type :vartype blockcounter: collections.Counter :ivar blockdict: index of all blocks by category :vartype blockdict: dict of lists :ivar name: name of this diagram :vartype name: str """ def __init__(self, name='main', **kwargs): self.wirelist = [] # list of all wires self.blocklist = [] # list of all blocks self.clocklist = [] # list of all clock sources self.compiled = False # network has been compiled self.blockcounter = Counter() self.name = name self.nstates = 0 self.ndstates = 0 self._issubsystem = False self.blocknames = {} self.options = None self.n_auto_sum = 0 self.n_auto_prod = 0 self.n_auto_const = 0 self.n_auto_gain = 0 def __getitem__(self, b): return self.blocknames[b] def __len__(self): return len(self.blocklist) @property def issubsystem(self): return self._issubsystem
[docs] def clock(self, *args, **kwargs): clock = Clock(*args, **kwargs) clock.bd = self self.clocklist.append(clock) return clock
[docs] def add_block(self, block): if block.name in self.blocknames: raise ValueError('block {} already added'.format(block.name)) block.id = len(self.blocklist) if block.name is None: i = self.blockcounter[block.type] self.blockcounter[block.type] += 1 block.name = "{:s}.{:d}".format(block.type, i) block.bd = self self.blocklist.append(block) # add to the list of available blocks if block in self.blocknames: raise Warning(f"block name {block} is not unique") self.blocknames[block.name] = block
[docs] def add_wire(self, wire, name=None): wire.id = len(self.wirelist) wire.name = name return self.wirelist.append(wire)
def __str__(self): return 'BlockDiagram: {:s}'.format(self.name) def __repr__(self): return str(self) + " with {:d} blocks and {:d} wires".format(len(self.blocklist), len(self.wirelist)) # for block in self.blocklist: # s += str(block) + "\n" # s += "\n" # for wire in self.wirelist: # s += str(wire) + "\n" # return s.lstrip("\n")
[docs] def ls(self): for k,v in self.blockdict.items(): print('{:12s}: '.format(k), ', '.join(v))
[docs] def connect(self, start, *ends, name=None): """ TODO: s.connect(out[3], in1[2], in2[3]) # one to many block[1] = SigGen() # use setitem block[1] = SumJunction(block2[3], block3[4]) * Gain(value=2) """ # convert to default plug on port 0 if need be if isinstance(start, Block): start = Plug(start, 0) start.type = 'start' for end in ends: if isinstance(end, Block): end = Plug(end, 0) end.type = 'end' if start.isslice and end.isslice: # we have a bundle of signals assert start.width == end.width, 'slice wires must have same width' for (s,e) in zip(start.portlist, end.portlist): wire = Wire( Plug(start.block, s, 'start'), Plug(end.block, e, 'end'), name) self.add_wire(wire) elif start.isslice and not end.isslice: # bundle going to a block assert start.width == start.block.nin, "bundle width doesn't match number of input ports" for inport,outport in enumerate(start.portlist): wire = Wire( Plug(start.block, outport, 'start'), Plug(end.block, inport, 'end'), name) self.add_wire(wire) else: wire = Wire(start, end, name) self.add_wire(wire)
# ---------------------------------------------------------------------- #
[docs] def compile(self, subsystem=False, doimport=True, evaluate=True, report=False, verbose=True): """ Compile the block diagram :param subsystem: importing a subsystems, defaults to False :type subsystem: bool, optional :param doimport: import subsystems, defaults to True :type doimport: bool, optional :raises RuntimeError: various block diagram errors :return: Compile status :rtype: bool Performs a number of operations: - Check sanity of block parameters - Recursively clone and import subsystems - Check for loops without dynamics - Check for inputs driven by more than one wire - Check for unconnected inputs and outputs - Link all output ports to outgoing wires - Link all input ports to incoming wires - Evaluate all blocks in the network """ # name the elements self.nblocks = len(self.blocklist) self.nwires = len(self.wirelist) error = False self.nstates = 0 self.ndstates = 0 self.statenames = [] self.dstatenames = [] self.blocknames = {} if not subsystem and verbose: print('\nCompiling:') # process all subsystem imports # ssblocks = [b for b in self.blocklist if b.type == 'subsystem'] # for b in ssblocks: # print(' importing subsystem', b.name) # if b.ssvar is not None: # print('-- Wiring in subsystem', b, 'from module local variable ', b.ssvar) self.blocklist, self.wirelist = self._subsystem_import(self, None) # check that wires all point to valid blocks for w in self.wirelist: if w.start.block not in self.blocklist: raise RuntimeError(f"wire {w} starts at unreferenced block {w.start.block}") if w.end.block not in self.blocklist: raise RuntimeError(f"wire {w} ends at unreferenced block {w.end.block}") # run block specific checks for b in self.blocklist: try: b.check() except: raise RuntimeError('block failed check ' + str(b)) # build a dictionary of all block names for b in self.blocklist: self.blocknames[b.name] = b # visit all stateful blocks for b in self.blocklist: if b.blockclass == 'transfer': self.nstates += b.nstates if b._state_names is not None: assert len(b._state_names) == b.nstates, 'number of state names not consistent with number of states' self.statenames.extend(b._state_names) else: # create default state names self.statenames.extend([b.name + 'x' + str(i) for i in range(0, b.nstates)]) if b.blockclass == 'clocked': self.ndstates += b.ndstates if b._state_names is not None: assert len(b._state_names) == b.nstates, 'number of state names not consistent with number of states' self.dstatenames.extend(b._state_names) else: # create default state names self.statenames.extend([b.name + 'X' + str(i) for i in range(0, b.nstates)]) # initialize lists of input and output ports for b in self.blocklist: b.outports = [[] for i in range(0, b.nout)] b.inports = [None for i in range(0, b.nin)] b._parents = [None for i in range(0, b.nin)] # connect the source and destination blocks to each wire for w in self.wirelist: try: w.start.block.add_outport(w) w.end.block.add_inport(w) w.end.block._parents[w.end.port] = w.start.block except: print('error connecting wire ', w.fullname + ': ', sys.exc_info()[1]) error = True # check connections every block for b in self.blocklist: # check all inputs are connected for port, connection in enumerate(b.inports): if connection is None: print(' ERROR: [{:s}] input {:d} is not connected'.format(str(b), port)) error = True # check all outputs are connected for port,connections in enumerate(b.outports): if len(connections) == 0: print(' INFORMATION: [{:s}] output {:d} is not connected'.format(str(b), port)) if b._inport_names is not None: assert len(b._inport_names) == b.nin, 'incorrect number of input names given: ' + str(b) if b._outport_names is not None: assert len(b._outport_names) == b.nout, 'incorrect number of output names given: ' + str(b) if b._state_names is not None: assert len(b._state_names) == b.nstates, 'incorrect number of state names given: ' + str(b) # check for cycles of function blocks def _DFS(path): start = path[0] tail = path[-1] for outgoing in tail.outports: # for every port on this block for w in outgoing: dest = w.end.block if dest == start: print(' ERROR: cycle found: ', ' - '.join([str(x) for x in path + [dest]])) return True if dest.blockclass == 'function': return _DFS(path + [dest]) # recurse return False for b in self.blocklist: if b.blockclass == 'function': # do depth first search looking for a cycle if _DFS([b]): error = True if error: if not subsystem: raise RuntimeError('could not compile system') # create the execution plan/schedule self.execution_plan() ## evaluate the network once to check out wire types x = self.getstate0() for clock in self.clocklist: clock._x = clock.getstate0() if report: self.report() self.plan_print() if not subsystem and evaluate: # run all the blocks for one step try: self.evaluate_plan(x, 0.0, sinks=False) except RuntimeError as err: print('\nFrom compile: unrecoverable error in value propagation:', err) traceback.print_exc(file=sys.stderr) error = True if error: # show report if there was an error if not report: self.report() if not subsystem: raise RuntimeError('could not compile system') else: self.compiled = True return self.compiled
def _subsystem_import(self, bd, sspath): blocks = [] wires = bd.wirelist for b in bd.blocklist: # rename the block to include subsystem path if sspath is not None: b.name = sspath + '/' + b.name if b.type == 'subsystem': # deal with a subsystem # - recurse to import it # - add its blocks and wires to the set ssb, ssw = self._subsystem_import(b.subsystem, b.name) blocks.extend(ssb) wires.extend(ssw) # INPORT/OUTPORT blocks now become simple pass throughs # same number of inputs and outputs b.inport.nin = b.inport.nout b.outport.nout = b.outport.nin # modify the wiring, keep the INPORT/OUTPORT blocks but lose # the SUBSYSTEM blocks for w in bd.wirelist: # for all wires at this level, find those that connect # to the subsystem and tweak them if w.start.block == b: # SS output w.start.block = b.outport if w.end.block == b: # SS input w.end.block = b.inport else: # not a subsystem, just add the block to the list blocks.append(b) # systematically renumber all blocks and wires for i, b in enumerate(blocks): b.id = i for i, w in enumerate(wires): w.id = i return blocks, wires # ---------------------------------------------------------------------- #
[docs] def evaluate_plan(self, x, t, checkfinite=True, debuglist=[], sinks=True): """ Evaluate all blocks in the network :param x: state :type x: numpy.ndarray :param t: current time :type t: float :param checkfinite: check for Inf or Nan values in block outputs :type checkfinite: bool :return: state derivative :rtype: numpy.ndarray Performs the following steps: 1. Partition the state vector ``x`` to all stateful blocks 2. Execute the blocks in the order given by the ``plan``. The block outputs are "sent" to their connected inputs. Sink blocks are not executed here, but after completion their inputs will all be valid. """ # TODO: don't copy outputs to inputs of next block, have inputs # pull the value from connected inputs try: self.state.t = t except: pass # TODO: this is super expensive because the string formatting # happens regardless of whether debugging is on self.DEBUG('state', '>>>>>>>>> t={}, x={} >>>>>>>>>>>>>>>>', t, x) # reset all the blocks ready for the evalation self.reset() # split the state vector to stateful blocks for b in self.blocklist: if b.blockclass == 'transfer': x = b.setstate(x) # split the discrete state vector to clocked blocks for clock in self.clocklist: clock.setstate() self.DEBUG('propagate', 't={:.3f}', t) for sequence, group in enumerate(self.plan): # self.DEBUG('propagate', '---- sequence = ', sequence) for b in group: # ask the block for output, check for errors try: out = b.output(t) except Exception as err: # output method failed, report it print('--Error at t={:f} when computing output of block {:s}'.format(t, str(b))) print(' {}'.format(err)) print(' inputs were: ', b.inputs) if b.nstates > 0: print(' state was: ', b._x) traceback.print_exc(file=sys.stderr) raise RuntimeError from None self.DEBUG('propagate', 'block {:s}: output = {}', b, out) # check that output is a list of correct length if not isinstance(out, (tuple, list)): raise AssertionError(f"block {b} output {b} must be a list: {type(out)}") if len(out) != b.nout: raise AssertionError(f"block {b} output {b} has incorrect length: {len(out)} instead of {b.nout}") # TODO check output validity once at the start # check it has no nan or inf values if checkfinite and isinstance(out, (int, float, np.ndarray)) and not np.isfinite(out).any(): raise RuntimeError(f"block {b} output contains NaN") # send block outputs to all downstream connected blocks for (port, outwires) in enumerate(b.outports): # every port value = out[port] for w in outwires: # every wire self.DEBUG('propagate', ' [{}] = {} --> {}[{}]', port, value, w.end.block.name, w.end.port) # send value to wire w.send(value) # TODO send return status no longer needed # TODO use common error handler in all cases above # gather the derivative YD = self.deriv() self.DEBUG('deriv', YD) return YD
[docs] def execution_plan(self): """ Create execution plan The plan is saved in the attribute ``plan`` and is a list ``[L0, L1, ... LN]`` where each ``Li`` is a list of blocks. The blocks in the lists are executed sequentially, ie. all the blocks in ``L0`` then all the blocks in ``L1`` etc. The plan ensures that the inputs of all blocks in ``Li`` have been previously computed. .. note:: - The plan is essentially a dataflow graph. - The blocks in list ``Li`` could potentially be executed in parallel. - Constant blocks and stateful blocks are all executed in ``L0`` - The block attribute ``_sequence`` is ``i`` and indicates its execution order :seealso: :func:`plan_print`, :func:`plan_dotfile` """ plan = [] group = [] for b in self.blocklist: b._sequence = None if b.blockclass in ('source', 'transfer', 'clocked'): b._sequence = 0 group.append(b) plan.append(group) sequence = len(plan) while True: group = [] for b in self.blocklist: if b._sequence is not None: continue # already has a sequence assigned if all([p._sequence < sequence if p._sequence is not None else False for p in b._parents]): group.append(b) for b in group.copy(): b._sequence = sequence if b.blockclass in ('sink', 'graphics'): group.remove(b) if len(group) == 0: break plan.append(group) sequence += 1 self.plan = plan
[docs] def plan_print(self): """ Display execution plan in tabular form :seealso: :func:`execution_plan`, :func:`plan_dotfile` """ table = ANSITable( Column("Sequence"), Column("Blocks", colalign='<', headalign='^'), border='thin' ) for sequence, group in enumerate(self.plan): table.row(sequence, ', '.join([str(b) for b in group])) table.print()
[docs] def plan_dotfile(self, filename): """ Write a GraphViz dot file representing the execution schedule :param file: Name of file to write to :type file: str The file can be processed using neato or dot:: % dot -Tpng -o out.png dotfile.dot Display execution plan as a dataflow graph. :seealso: :func:`execution_plan`, :func:`plan_print` """ if isinstance(filename, str): file = open(filename, 'w') else: file = filename header = r"""digraph G { graph [splines=ortho, rankdir=LR, splines=spline] node [shape=box] """ file.write(header) for sequence, group in enumerate(self.plan): # for each execution group, place the blocks in a subgraph file.write('\tsubgraph step{:d} {{\n'.format(sequence)) file.write('\t\trank=same;\n') for b in group: file.write('\t\t"{:s}"\n'.format(b.name)) file.write('\t}\n\n') # connect them to their parents, except if a transfer block for b in self.blocklist: if not b.blockclass == 'transfer': for p in b._parents: file.write('\t"{:s}" -> "{:s}"\n'.format(p.name, b.name)) file.write('}\n')
# ---------------------------------------------------------------------- # def _debugger(self, state=None, integrator=None): if state.t_stop is not None and state.t < state.t_stop: return state.t_stop = None print('\n') while True: cmd = input(f"(bdsim, t={state.t:.4f}) ") if len(cmd) == 0: continue if cmd[0] == 'p': # print variables if len(cmd) > 1: id = int(cmd[1:]) b = self.blocklist[id] print(b.name, b.output(t=state.t)) else: for b in self.blocklist: if b.nout > 0: print(b.name, b.output(t=state.t)) elif cmd[0] == 'i': print(integrator.status, integrator.step_size, integrator.nfev) elif cmd[0] == 's': # step break elif cmd[0] == 'c': # continue self.debug_stop = False self.t_stop = None break elif cmd[0] == 't': self.t_stop = float(cmd[1:]) break elif cmd[0] == 'q': sys.exit(1) elif cmd[0] in 'h?': print("p print all outputs") print("pI print block id I output") print("i print integrator status") print("s single step") print("c continue") print("tT stop at or after time T") print("q quit") # ---------------------------------------------------------------------- #
[docs] def report(self): """ Print a tabular report about the block diagram """ # print all the blocks print('\nBlocks::\n') table = ANSITable( Column("id"), Column("name"), Column("nin"), Column("nout"), Column("nstate"), Column("ndstate"), Column("type", headalign="^", colalign="<"), border="thin" ) for b in self.blocklist: table.row( b.id, str(b), b.nin, b.nout, b.nstates, b.ndstates, b.type) table.print() # print all the wires print('\nWires::\n') table = ANSITable( Column("id"), Column("from", headalign="^"), Column("to", headalign="^"), Column("description", headalign="^", colalign="<"), Column("type", headalign="^", colalign="<"), border="thin" ) for w in self.wirelist: start = "{:d}[{:d}]".format(w.start.block.id, w.start.port) end = "{:d}[{:d}]".format(w.end.block.id, w.end.port) try: value = w.end.block.inputs[w.end.port] typ = type(value).__name__ if isinstance(value, np.ndarray): typ += ' {:s}'.format(str(value.shape)) except: typ = '??' table.row( w.id, start, end, w.fullname, typ) table.print() if len(self.clocklist) > 0: # print all the clocked blocks print('\nClocked blocks::\n') table = ANSITable( Column("id"), Column("block"), Column("clock"), Column("period"), Column("offset"), border="thin" ) for b in self.blocklist: if b.blockclass == 'clocked': c = b.clock table.row( b.id, str(b), c.name, c.T, c.offset) table.print() print('\nContinuous state variables: {:d}'.format(self.nstates)) print( 'Discrete state variables: {:d}'.format(self.ndstates)) if not self.compiled: print('** System has not been compiled, or had a compile time error')
# ---------------------------------------------------------------------- # def _error_handler(self, where, block): # called from except clause import traceback import types t, v, tb = sys.exc_info() # get the exception print(fg('red')) # red text # print the traceback print(f"[{where}]: exception {t.__name__} occurred in {block.type} block {block.name} ") print(f" {v}\n") traceback.print_tb(tb) # print all block inputs print() for i in range(block.nin): input = block.inputs[i] print(f"input {i} from {block.inports[i].start.block.name} [{input.__class__.__name__}]") print(' ', input) print(attr(0)) # default text # traceback = err[2] # back_frame = traceback.tb_frame.f_back # back_tb = types.TracebackType(tb_next=None, # tb_frame=back_frame, # tb_lasti=back_frame.f_lasti, # tb_lineno=back_frame.f_lineno) # raise RuntimeError('Fatal failure').with_traceback(back_tb) raise RuntimeError('Fatal failure') from None
[docs] def getstate0(self): # get the state from each stateful block x0 = np.array([]) for b in self.blocklist: try: if b.blockclass == 'transfer': x0 = np.r_[x0, b.getstate0()] #print('x0', x0) except: self._error_handler('getstate0', b) return x0
[docs] def reset(self): """ Reset conditions within every active block. Most importantly, all inputs are marked as unknown. Invokes the `reset` method on all blocks. """ for b in self.blocklist: try: b.reset() except: self._error_handler('reset', b)
[docs] def step(self, state=None): """ Step all blocks :param state: simulation state, defaults to None :type state: SimState, optional :param graphics: graphics enabled, defaults to False :type graphics: bool, optional Tell all blocks to take action on new inputs by invoking their ``step`` method and passing the ``state`` object. Used to save results to a figure or file .. note:: - if ``graphics`` is False, Graphics blocks are not called """ # TODO could be done by output method, even if no outputs for b in self.blocklist: if state.options.graphics and b.isgraphics: try: b.step(state=state) state.count += 1 except: self._error_handler('step', b)
[docs] def deriv(self): """ Harvest derivatives from all blocks . """ YD = np.array([]) for b in self.blocklist: if b.blockclass == 'transfer': try: yd = b.deriv().flatten() if not isinstance(yd, np.ndarray): raise AssertionError(f"deriv: block {b} did not return ndarray") if yd.ndim != 1 or yd.shape[0] != b.nstates: raise AssertionError(f"deriv: block {b} returns wrong shape {yd.shape}, should be ({b.nstates},)") YD = np.r_[YD, yd] except: self._error_handler('deriv', b) return YD
[docs] def start(self, graphics=False, state=None, **kwargs): """ Start all blocks :param state: simulation state, defaults to None :type state: SimState, optional :param graphics: graphics enabled, defaults to False :type graphics: bool, optional Inform all blocks that BlockDiagram execution is about to start by invoking their ``start`` method and passing the ``state`` object. Used to open files, create figures etc. .. note:: if ``graphics`` is False, Graphics blocks are not called """ for c in self.clocklist: try: c.start(state=state, **kwargs) except: self._error_handler('start clock', c) # safe wrapper for block starting, does error handling for b in self.blocklist: if b.isgraphics and not graphics: continue # print('starting block', b) try: b.start(state=state, **kwargs) except: self._error_handler('block.start', b)
[docs] def initialstate(self): for b in self.blocklist: if b.blockclass in ('transfer', 'clocked'): b._x = b._x0
[docs] def done(self, graphics=False, **kwargs): """ Finishup all blocks :param state: simulation state, defaults to None :type state: SimState, optional :param graphics: graphics enabled, defaults to False :type graphics: bool, optional Inform all blocks that BlockDiagram execution is complete by invoking their ``done`` method and passing options. Used to close files, display figures etc. .. note:: if ``graphics`` is False, Graphics blocks are not called """ for b in self.blocklist: if b.isgraphics and not graphics: continue try: b.done(**kwargs) except: self._error_handler('block.done', b)
[docs] def dotfile(self, filename): """ Write a GraphViz dot file representing the network. :param file: Name of file to write to :type file: str The file can be processed using neato or dot:: % dot -Tpng -o out.png dotfile.dot """ if isinstance(filename, str): file = open(filename, 'w') else: file = filename header = r"""digraph G { graph [splines=ortho, rankdir=LR] node [shape=box] """ file.write(header) # add the blocks for b in self.blocklist: options = [] if b.blockclass == "source": options.append("shape=box3d") elif b.blockclass == "sink": options.append("shape=folder") elif b.blockclass == "function": if b.type == 'gain': options.append("shape=triangle") options.append("orientation=-90") options.append('label="{:g}"'.format(b.gain)) elif b.type == 'sum': options.append("shape=point") elif b.blockclass == 'transfer': options.append("shape=component") if b.pos is not None: options.append('pos="{:g},{:g}!"'.format(b.pos[0], b.pos[1])) options.append('xlabel=<<BR/><FONT POINT-SIZE="8" COLOR="blue">{:s}</FONT>>'.format(b.type)) file.write('\t"{:s}" [{:s}]\n'.format(b.name, ', '.join(options))) # add the wires for w in self.wirelist: options = [] #options.append('xlabel="{:s}"'.format(w.name)) if w.end.block.type == 'sum': options.append('headlabel="{:s} "'.format(w.end.block.signs[w.end.port])) file.write('\t"{:s}" -> "{:s}" [{:s}]\n'.format(w.start.block.name, w.end.block.name, ', '.join(options))) file.write('}\n')
[docs] def blockvalues(self): for b in self.blocklist: print('Block {:s}:'.format(b.name)) print(' inputs: ', b.inputs) print(' outputs: ', b.output(t=0))
[docs] def DEBUG(self, debug, fmt, *args): if debug[0] in self.options.debug: print('DEBUG.{:s}: ' + fmt.format(*args))
if __name__ == "__main__": import bdsim bd = bdsim.BlockDiagram() # define the blocks demand = bd.STEP(T=1, pos=(0,0), name='demand') sum = bd.SUM('+-', pos=(1,0)) gain = bd.GAIN(10, pos=(1.5,0)) plant = bd.LTI_SISO(0.5, [2, 1], name='plant', pos=(3,0)) #scope = bd.SCOPE(pos=(4,0), styles=[{'color': 'blue'}, {'color': 'red', 'linestyle': '--'}) scope = bd.SCOPE(nin=2, styles=['k', 'r--'], pos=(4,0)) # connect the blocks bd.connect(demand, sum[0], scope[1]) bd.connect(plant, sum[1]) bd.connect(sum, gain) bd.connect(gain, plant) bd.connect(plant, scope[0]) bd.compile() # check the diagram bd.report() # list all blocks and wires bd.run(5, debug=True) # from pathlib import Path # exec(open(Path(__file__).parent.absolute() / "test_blockdiagram.py").read())