Circuit notation and Compilation
Lucipy ships with a number of tools to manipulate the LUCIDAC interconnection matrix (also known as UCI matrix).
Reconfigurable analog circuit model
The focus of this library is on REV0 LUCIDAC hardware. What follows is quickly drawn ASCII diagram of this particular analog computer topology (we have much nicer schemata available which will evventually replace this one):
┌─────────────────────── ┌──────────────────────────────┐
│Math Block M1 m0 0 -> │U Block │
│4 x Multipliers m1 1 │16 inputs, fanout on │
│2in, 1out each m2 2 │32 outputs (called lanes) │
│ m3 3 │ │
┌───► │ . 4 ───► │It is a 16x32 bitmatrix. │
│ │constant givers . 5 │ │
│ │ . 6 │ │
│ │ . 7 -> │ │
│ └─────────────────────── │ │
│ │ │
│ ┌─────────────────────── │ │
│ │Math Block M2 i0 8 -> │ │
│ │8 x Integrators i1 9 │ │
│ │1in, 1out each i2 10 │ │
│ │ i3 11 ───► │ │
│ ┌► │ i4 12 │ │
│ │ │ i i5 13 │ │
│ │ │ i6 14 │ │
│ │ │ i7 15 -> │ │
│ │ └─────────────────────── └──────────────┬───────────────┘
│ │ │
│ │ ┌──────────────▼───────────────┐
│ │ │C block. │
│ │ │32 coefficients │
│ │ │value [-20,+20] each │
│ │ │ │
│ │ └──────────────┬───────────────┘
│ │ │
│ │ ┌─────────────────────── ┌──────────────▼───────────────┐
│ │ │Math Block M1 m0a 0 <- │I Block │
│ │ │4 x Multipliers m0b 1 │32 inputs, fanin to │
│ │ │2in, 1out each m1a 2 │16 outputs │
│ │ │ m1b 3 ◄───┤ │
└──┼── │ m2a 4 │It is a 32x16 bitmatrix │
│ │constant givers m2b 5 │which performs implicit │
│ │ m3a 6 │summation of currents │
│ │ m3b 7 <- │ │
│ └─────────────────────── │ │
│ │ │
│ ┌─────────────────────── │ │
│ │Math Block M2 i0 8 <- │ │
│ │8 x Integrators i1 9 │ │
│ │1in, 1out each i2 10 │ │
│ │ i3 11 │ │
│ │ i4 12 ◄───┤ │
└───┤ i5 13 │ │
│ i6 14 │ │
│ i7 15 <- │ │
└─────────────────────── └──────────────────────────────┘
Concept
The main usage shall be demonstrated on the Lorenz attractor example (see also Example circuits). It can be basically entered as element connection diagram:
from lucipy import Circuit
lorenz = Circuit()
x = lorenz.int(ic=-1)
y = lorenz.int()
z = lorenz.int()
mxy = lorenz.mul() # -x*y
xs = lorenz.mul() # +x*s
c = lorenz.const()
lorenz.connect(x, x, weight=-1)
lorenz.connect(y, x, weight=+1.8)
lorenz.connect(x, mxy.a)
lorenz.connect(y, mxy.b)
lorenz.connect(mxy, z, weight=-1.5)
lorenz.connect(z, z, weight=-0.2667)
lorenz.connect(x, xs.a, weight=-1)
lorenz.connect(z, xs.b, weight=+2.67)
lorenz.connect(c, xs.b, weight=-1)
lorenz.connect(xs, y, weight=-1.536)
lorenz.connect(y, y, weight=-0.1)
Internally, the library stores Route tuples:
>> print(lorenz)
Routing([Route(uin=8, lane=0, coeff=-1, iout=8),
Route(uin=9, lane=1, coeff=1.8, iout=8),
Route(uin=8, lane=2, coeff=1, iout=0),
Route(uin=9, lane=3, coeff=1, iout=1),
Route(uin=0, lane=4, coeff=-1.5, iout=10),
Route(uin=10, lane=5, coeff=-0.2667, iout=10),
Route(uin=8, lane=14, coeff=-1, iout=2),
Route(uin=10, lane=15, coeff=2.67, iout=3),
Route(uin=4, lane=16, coeff=-1, iout=3),
Route(uin=1, lane=17, coeff=-1.536, iout=9),
Route(uin=9, lane=18, coeff=-0.1, iout=9),
Route(uin=8, lane=8, coeff=0, iout=6),
Route(uin=9, lane=9, coeff=0, iout=6)])
By concept, there is no symbolic representation but instead immediate destruction of any symbolics. However, the “initial format” can be easily decompiled:
>> print(lorenz.reverse())
Connection(Int0, Int0, weight=-1),
Connection(Int1, Int0, weight=1.8),
Connection(Int0, Mul0.a),
Connection(Int1, Mul0.a),
Connection(Mul0, Int2, weight=-1.5),
Connection(Int2, Int2, weight=-0.2667),
Connection(Int0, Mul1.a, weight=-1),
Connection(Int2, Mul1.a, weight=2.67),
Connection(Const0, Mul1.a, weight=-1),
Connection(Mul1, Int1, weight=-1.536),
Connection(Int1, Int1, weight=-0.1),
Connection(Int0, Mul3.a, weight=0),
Connection(Int1, Mul3.a, weight=0)
Where Connection() is just a function that generates a Route.
Export formats
There are various methods available to convert a Routing list to other representations. First of all, the LUCIDAC sparse JSON configuration format can be generated:
>> lorenz.generate()
{'/U': {'outputs': [8,
9,
8,
9,
0,
10,
None,
None,
8,
9,
None,
None,
None,
None,
8,
10,
4,
1,
9,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None]},
'/C': {'elements': [-1,
1.8,
1,
1,
-1.5,
-0.2667,
0,
0,
0,
0,
0,
0,
0,
0,
-1,
2.67,
-1,
-1.536,
-0.1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0]},
'/I': {'outputs': [[2],
[3],
[14],
[15, 16],
[],
[],
[8, 9],
[],
[0, 1],
[17, 18],
[4, 5],
[],
[],
[],
[],
[]]},
'/M0': {'elements': [{'k': 10000, 'ic': -1},
{'k': 10000, 'ic': 0},
{'k': 10000, 'ic': 0},
{'k': 10000, 'ic': 0.0},
{'k': 10000, 'ic': 0.0},
{'k': 10000, 'ic': 0.0},
{'k': 10000, 'ic': 0.0},
{'k': 10000, 'ic': 0.0}]},
'/M1': {}}
Therefore one can straight forwardly program a LUCIDAC by writing
from lucipy import LUCIDAC, Circuit
lorenz = Circuit()
# ... the circuit from above ...
hc = LUCIDAC()
hc.set_config(lorenz.generate())
hc.start_run() # ...
Also other formats can be generated, for instance a single dense 16x16 matrix showing the interconnects, weights and implict sums between the Math blocks:
>> import numpy as np
>> np.set_printoptions(edgeitems=30, linewidth=1000, suppress=True)
>> lorenz.to_dense_matrix().shape
(16,16)
>> lorenz.to_dense_matrix()
array([[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 1. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 1. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , -1. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , -1. , 0. , 0. , 0. , 0. , 0. , 2.67 , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , -1. , 1.8 , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , -1.536 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , -0.1 , 0. , 0. , 0. , 0. , 0. , 0. ],
[-1.5 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , -0.2667, 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[ 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ]])
This is an export to the pybrid DSL:
>> print(lorenz.to_pybrid_cli())
set-alias * carrier
set-element-config carrier/0/M0/0 ic -1
set-element-config carrier/0/M0/0 k -1
set-element-config carrier/0/M0/1 k 0
set-element-config carrier/0/M0/2 k 0
set-element-config carrier/0/M0/3 k 0.0
set-element-config carrier/0/M0/4 k 0.0
set-element-config carrier/0/M0/5 k 0.0
set-element-config carrier/0/M0/6 k 0.0
set-element-config carrier/0/M0/7 k 0.0
route -- carrier/0 8 0 -1.000 8
route -- carrier/0 9 1 1.800 8
route -- carrier/0 8 2 1.000 0
route -- carrier/0 9 3 1.000 1
route -- carrier/0 0 4 -1.500 10
route -- carrier/0 10 5 -0.267 10
route -- carrier/0 8 14 -1.000 2
route -- carrier/0 10 15 2.670 3
route -- carrier/0 4 16 -1.000 3
route -- carrier/0 1 17 -1.536 9
route -- carrier/0 9 18 -0.100 9
route -- carrier/0 8 8 0.000 6
route -- carrier/0 9 9 0.000 6
# run --op-time 500000
Here comes an export to a Sympy system:
>> lorenz.to_sympy()
[Eq(m_0(t), -i_0(t)*i_1(t)),
Eq(m_1(t), (2.67*i_2(t) - 1.0)*i_0(t)),
Eq(Derivative(i_0(t), t), -i_0(t) + 1.8*i_1(t)),
Eq(Derivative(i_1(t), t), -0.1*i_1(t) - 1.536*m_1(t)),
Eq(Derivative(i_2(t), t), -0.2667*i_2(t) - 1.5*m_0(t))]
This can be tailored in order to have something which can be straightforwardly numerically solved:
>> [ eq.rhs for eq in lorenz.to_sympy(int_names="xyz", subst_mul=True, no_func_t=True) ]
[-x + 1.8*y, -1.536*x*(2.67*z - 1.0) - 0.1*y, 1.5*x*y - 0.2667*z]
See Example circuits for further examples.
API Docs
Lucipy Circuits: A shim over the routes.
This class structure provides a minimal level of user-friendlyness to allow route-based programming on the LUCIDAC. This effectively means a paradigm where one connects elements from math blocks to each other, with a coefficient inbetween. And the assignment throught the UCI matrix is done greedily by “first come, first serve” without any constraints.
The code is heavily inspired by the LUCIGUI lucisim typescript compiler, which is however much bigger and creates an AST before mapping.
In contrast, the approach demonstrated here is not even enough for REV0 connecting ExtIn/ADC/etc. But it makes it very simple and transparent to work with routes and setup the circuit configuration low level.
- lucipy.circuits.window(seq, n=2)[source]
Returns a sliding window (of width n) over data from the iterable
- lucipy.circuits.next_free(occupied: list[bool], append_to: int | None = None) int | None[source]
Looks for the first False value within a list of truth values.
>>> next_free([1,1,0,1,0,0]) # using ints instead of booleans for brevety 2
If no more value is free in list, it can append up to a given value
>>> next_free([True]*4, append_to=3) # None, nothing free >>> next_free([True]*4, append_to=6) 4
- class lucipy.circuits.Int(id, out, a)
- a
Alias for field number 2
- id
Alias for field number 0
- out
Alias for field number 1
- class lucipy.circuits.Mul(id, out, a, b)
- a
Alias for field number 2
- b
Alias for field number 3
- id
Alias for field number 0
- out
Alias for field number 1
- class lucipy.circuits.Reservoir(allocation=None, **kwargs)[source]
This is basically the entities list, tracking which one is already handed out (“allocated”) or not.
Note that the Mul/Int classes only hold some integers. In contrast, the configurable properties of the stateful computing element (Integrator) is managed by the MIntBlock class below.
- alloc(t: Int | Mul | Const, id=None)[source]
Allocate computing elements.
>>> r = Reservoir() >>> r.alloc(Int,1) Int(id=1, out=9, a=9) >>> r.alloc(Int) Int(id=0, out=8, a=8) >>> r.alloc(Int) Int(id=2, out=10, a=10)
- int(id=None)[source]
Allocate an Integrator. If you pass an id, allocate that particular integrator.
- class lucipy.circuits.Route(uin, lane, coeff, iout)
- coeff
Alias for field number 2
- iout
Alias for field number 3
- lane
Alias for field number 1
- uin
Alias for field number 0
- lucipy.circuits.Connection(source: Int | Mul | Const | int, target: Int | Mul | Const | int, weight=1)[source]
Transforms an argument list somewhat similar to a “logical route” in the lucicon code to a physical route.
>>> r = Reservoir() >>> I1, M1 = r.int(), r.mul() >>> Connection(M1.a, I1) Route(uin=0, lane=None, coeff=1, iout=8)
- class lucipy.circuits.Routing(routes: list[Route] | None = None, **kwargs)[source]
This class provides a route-tuple like interface to the UCI block and generates the Output-centric matrix configuration at the end.
- routes2matrix()[source]
AoS->SoA Reduced Matrix (=input) representation, as in lucidon/mapping.ts These are the spare matrix formats used by the protocol.
- static input2output(inmat, keep_arrays=True)[source]
Maps Array<int,32> onto Array<Array<int>|int, 16>
- generate()[source]
Generate the configuration data structure required by the protocol, which is that “output-centric configuration”.
- to_pybrid_cli()[source]
Generate the Pybrid-CLI commands as string out of this Route representation
- to_dense_matrix()[source]
Generates a dense numpy matrix for the UCI block, i.e. a real-valued 16x16 matrix with bounded values [-20,20] where at most 32 entries are non-zero.
- to_sympy(int_names=None, subst_mul=False, no_func_t=False)[source]
Creates an ODE system in sympy based on the circuit.
- Parameters:
int_names – Allows to overwrite the names of the integators. By default they are called i_0, i_1, … i_7. By providing a list such as [“x”, “y”, “z”], these names will be taken. If the list is shorter then length 8, it will be filled up with the default names.
subst_mul – Substitute multiplications, getting rid of explicit Eq(m_0, …) statements. Useful for using the equation set within an ODE solver because the state vector is exactly the same length of the entries.
no_func_t – Write f instead of f(t) on the RHS, allowing for denser notations, helps also in some solver contexts.
Warning
This method is part of the Routing class and therefore knows nothing about the MIntBlock settings, in particular not the k0. You have to apply different k0 values by yourself if neccessary! This can be as easy as to multiply the equations rhs with the appropriate k0 factor.
Example for making use of the output within a scipy solver:
from sympy import latex, lambdify, symbols from scipy.integrate import solve_ivp xyz = list("xyz") eqs = lucidac_circuit.to_sympy(xyz, subst_mul=True, no_func_t=True) print(eqs) print(latex(eqs)) rhs = [ e.rhs for e in eqs ] x,y,z = symbols(xyz) f = lambdify((x,y,z), rhs) f(1,2,3) # works sol = solve_ivp(f, (0, 10), [1,2,3])
- class lucipy.circuits.Circuit(routes: list[Route] = [])[source]
A one stop-shop of a compiler! This class collects all independent behaviour in a neat single-class interface. This allows it, for instance, to provide an improved version of the Reservoir’s int() method which also sets the Int state in one go. It also can generate the final configuration format required for LUCIDAC.
- int(*, id=None, ic=0, slow=False)[source]
Allocate an Integrator and set it’s initial conditions and k0 factor at the same time.