Skip to content

Tutorial 33: Equation-to-Verilog Compiler

SC-NeuroCore can compile arbitrary ODE strings into synthesizable Q8.8 fixed-point Verilog RTL in a single function call. Write your neuron equations in Brian2-style syntax, get both a Python simulation model and a Verilog module ready for FPGA.

The Problem

Designing custom neuron models for FPGA requires: 1. Deriving fixed-point arithmetic for each equation 2. Writing Verilog with proper overflow handling 3. Verifying Python and Verilog produce identical results

The equation compiler automates all three steps.

1. One-Liner: ODE String to FPGA

from sc_neurocore.compiler.equation_compiler import equation_to_fpga

# Define a simple LIF neuron as an ODE string
neuron, verilog = equation_to_fpga(
    "dv/dt = (-v + R*I) / tau",
    threshold="v > 1.0",
    reset="v = 0.0",
    params={"R": 1.0, "tau": 20.0},
    module_name="custom_lif",
)

# `neuron` is a Python EquationNeuron — simulate it
# I must exceed threshold (1.0) for the LIF to spike: steady state v = R*I
for t in range(200):
    spike = neuron.step(I=25.0)
    if spike:
        print(f"  Spike at t={t}")

# `verilog` is synthesizable Q8.8 SystemVerilog
with open("custom_lif.sv", "w") as f:
    f.write(verilog)
print(f"Generated {len(verilog)} chars of Verilog")

2. Multi-Variable ODEs

The compiler handles coupled differential equations:

# FitzHugh-Nagumo (2 variables)
neuron, verilog = equation_to_fpga(
    "dv/dt = v - v**3/3 - w + I",
    "dw/dt = 0.08 * (v + 0.7 - 0.8*w)",
    threshold="v > 1.0",
    reset="v = -1.0",
    params={},
    module_name="fitzhugh_nagumo",
)

# Simulate and plot
import numpy as np
voltages = []
for t in range(1000):
    neuron.step(I=0.5)
    voltages.append(neuron.state["v"])
# Izhikevich (fast spiking)
neuron, verilog = equation_to_fpga(
    "dv/dt = 0.04*v**2 + 5*v + 140 - u + I",
    "du/dt = a * (b*v - u)",
    threshold="v >= 30.0",
    reset="v = c; u = u + d",
    params={"a": 0.1, "b": 0.2, "c": -65.0, "d": 2.0},
    module_name="izhikevich_fs",
)

3. The EquationNeuron Class

Build custom neurons from equations without the Verilog step:

from sc_neurocore.neurons.equation_builder import from_equations

neuron = from_equations(
    "dv/dt = (-v + I) / tau",
    threshold="v > 1.0",
    reset="v = 0.0",
    params={"tau": 10.0},
    dt=0.1,
)

# Same step()/reset() API as all 122 models
for t in range(500):
    spike = neuron.step(I=0.6)

4. Q8.8 Fixed-Point Arithmetic

The compiler converts floating-point equations to Q8.8 (16-bit signed, 8 fractional bits):

Float value Q8.8 representation Range
1.0 256 (0x0100)
0.5 128 (0x0080)
-1.0 -256 (0xFF00)
Max 127.996 32767
Min -128.0 -32768
Resolution 1/256 = 0.00390625

Multiplication: (A * B) >> 8 with saturation. The Verilog output includes explicit overflow clamping on every arithmetic operation.

5. AST-to-Verilog Expression Mapping

The compiler parses Python/Brian2 AST and emits Verilog:

Python expression Verilog output
v + I v_reg + I_in
v * 0.04 (v_reg * 16'd10) >>> 8
v ** 2 (v_reg * v_reg) >>> 8
v > 1.0 v_reg > 16'sd256
-v (-v_reg)

6. Generate a Testbench

from sc_neurocore.compiler.equation_compiler import equation_to_fpga, generate_testbench

# First create the neuron
neuron, verilog = equation_to_fpga(
    "dv/dt = (-v + R*I) / tau",
    threshold="v > 1.0", reset="v = 0.0",
    params={"R": 1.0, "tau": 20.0}, module_name="custom_lif",
)

# Generate testbench from the neuron
tb = generate_testbench(
    neuron,
    module_name="custom_lif",
    n_steps=200,
    input_current=0.8,
)
with open("tb_custom_lif.sv", "w") as f:
    f.write(tb)

7. CLI: One-Command Compile

The sc-neurocore compile command wraps the full pipeline into a single invocation — ODE string to Verilog RTL to FPGA bitstream:

# Basic: ODE → Verilog
sc-neurocore compile "dv/dt = -(v - E_L)/tau_m + I/C" \
    --threshold "v > -50" --reset "v = -65" \
    --params "E_L=-65,tau_m=10,C=1" --init "v=-65" \
    -o build/my_lif

# With testbench
sc-neurocore compile "dv/dt = -(v - E_L)/tau_m + I/C" \
    --threshold "v > -50" --reset "v = -65" \
    --params "E_L=-65,tau_m=10,C=1" --init "v=-65" \
    --testbench -o build/my_lif

# Full pipeline: compile + synthesize (requires Yosys)
sc-neurocore compile "dv/dt = -(v - E_L)/tau_m + I/C" \
    --threshold "v > -50" --reset "v = -65" \
    --params "E_L=-65,tau_m=10,C=1" --init "v=-65" \
    --target ice40 --testbench --synthesize -o build/my_lif
flowchart LR
    A["ODE string"] --> B["equation_to_fpga()"]
    B --> C["Verilog RTL<br/>Q8.8 fixed-point"]
    B --> D["generate_testbench()"]
    C --> E["Yosys synthesis"]
    E --> F["nextpnr P&R"]
    F --> G["icepack bitstream"]

    style A fill:#e1f5fe
    style C fill:#e8f5e9
    style G fill:#fce4ec

Output:

[1/4] Parsing ODE: dv/dt = -(v - E_L)/tau_m + I/C
  State variables: ['v']
  Parameters: ['E_L', 'tau_m', 'C']
[2/4] Verilog written: build/my_lif/sc_equation_neuron.v
[3/4] Testbench written: build/my_lif/tb_sc_equation_neuron.v
[4/4] Synthesis complete
  Number of cells: 89
  Number of SB_LUT4: 73
  Synthesis JSON: build/my_lif/sc_equation_neuron.json

8. Transcendental Functions

The compiler supports transcendental functions via 16-entry Q8.8 lookup tables. Each function is a piecewise constant indexed by the top 4 bits of the unsigned input, covering the [-8, +8) range.

Function Python/Brian2 Verilog Accuracy
exp(x) exp(v) 16-entry LUT ~1-2% over [-3, 3]
log(x) log(v) 16-entry LUT ~2-3% over [0.1, 8]
sqrt(x) sqrt(v) 16-entry LUT ~1% over [0, 8]
tanh(x) tanh(v) 16-entry LUT ~1% over [-3, 3]
sigmoid(x) sigmoid(v) 16-entry LUT ~1% over [-4, 4]
sin(x) sin(v) 16-entry LUT ~2%
cos(x) cos(v) 16-entry LUT ~2%
abs(x) abs(v) ternary exact
clip(x, lo, hi) clip(v, -1, 1) ternary exact
max(a, b) max(v, 0) ternary exact
min(a, b) min(v, 1) ternary exact

Example — Hodgkin-Huxley with exponentials:

neuron, verilog = equation_to_fpga(
    "dv/dt = (I - g_Na*m**3*h*(v-E_Na) - g_K*n**4*(v-E_K) - g_L*(v-E_L)) / C_m",
    "dm/dt = alpha_m*(1-m) - beta_m*m",
    "dh/dt = alpha_h*(1-h) - beta_h*h",
    "dn/dt = alpha_n*(1-n) - beta_n*n",
    threshold="v > 0",
    reset="v = -65",
    params={"g_Na": 120, "g_K": 36, "g_L": 0.3,
            "E_Na": 50, "E_K": -77, "E_L": -54.4, "C_m": 1,
            "alpha_m": 0.1, "beta_m": 4.0,
            "alpha_h": 0.07, "beta_h": 1.0,
            "alpha_n": 0.01, "beta_n": 0.125},
    init={"v": -65, "m": 0.05, "h": 0.6, "n": 0.32},
    module_name="hh_neuron",
)

9. Saturating Arithmetic

All next-state updates include overflow protection:

// Raw sum may exceed 16-bit range
wire signed [16:0] v_raw = v_reg + dv;
// Clamp to [-32768, 32767]
wire signed [15:0] v_next =
    (v_raw > 17'sd32767) ? 16'sd32767 :
    (v_raw < -17'sd32768) ? -16'sd32768 :
    v_raw[15:0];

Without saturation, integer overflow wraps around silently — a neuron at +127 receiving +5 input would jump to -124 instead of clamping at +127. The compiler prevents this automatically.

Supported ODE Features

Feature Supported Example
Linear terms Yes -v / tau
Polynomial (2-8) Yes v**2, v**3, v**4
Products Yes a * b * v
Addition/subtraction Yes v - w + I
Transcendentals Yes exp(v), tanh(v), sigmoid(v), sin(x), cos(x)
abs / clip / max / min Yes abs(v), clip(v, -1, 1)
Threshold comparison Yes v > 1.0, v >= 30
Multi-variable reset Yes v = c; u = u + d
Named parameters Yes tau, a, b, c, d
External input Yes I (injected per step)
Saturating overflow Yes automatic on all additions

Further Reading