codex/neuron-input-timing-and-spike-output.py

neuron-input-timing-and-spike-output.py
"""Exact illustrative LIF responses, never empirical voltage recordings.

Python 3.14 / matplotlib 3.10.7 / numpy 2.3.5.
Run from _media; output PNG uses this script's basename in the current directory.
Units below: ms, mV, pA. The factor .1 converts 100 MOhm * pA to mV.
"""
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

REST = -65.0
THRESHOLD = -50.0
TAU = 20.0
RESISTANCE = .1  # mV/pA
REFRACTORY = 2.0
PROTOCOLS = [
    ("One input", [(10.0, 50.0, 100.0)]),
    ("Two coincident inputs", [(10.0, 50.0, 100.0), (10.0, 50.0, 100.0)]),
    ("Two separated inputs", [(10.0, 50.0, 100.0), (70.0, 110.0, 100.0)]),
]


def segments(pulses, end=130.0):
    """Compute exact constant-current segments and exact threshold events."""
    boundaries = sorted({0., end} | {t for start, stop, _ in pulses for t in (start, stop)})
    result, spikes, holds = [], [], []
    voltage, refractory_end = REST, -np.inf
    for start, stop in zip(boundaries[:-1], boundaries[1:]):
        current = sum(i for a, b, i in pulses if a <= start < b)
        time = start
        while time < stop - 1e-12:
            if time < refractory_end:
                until = min(stop, refractory_end)
                result.append((time, until, REST, REST, current, True))
                time, voltage = until, REST
                continue
            equilibrium = REST + RESISTANCE * current
            latency = np.inf
            if equilibrium > THRESHOLD:
                latency = TAU * np.log((equilibrium - voltage) / (equilibrium - THRESHOLD))
            until = min(stop, time + latency)
            result.append((time, until, voltage, equilibrium, current, False))
            voltage = equilibrium + (voltage - equilibrium) * np.exp(-(until - time) / TAU)
            if time + latency <= stop:
                spikes.append(until)
                holds.append((until, until + REFRACTORY))
                voltage = REST
                refractory_end = until + REFRACTORY
            time = until
    return result, np.asarray(spikes), holds


def main():
    colors = ["#28628a", "#b3541b", "#287a61"]
    fig, axs = plt.subplots(2, 3, figsize=(10.5, 5.5), sharex=True, sharey="row",
                            gridspec_kw={"height_ratios": [1, 2]})
    fig.set_facecolor("white")
    for col, ((title, pulses), color) in enumerate(zip(PROTOCOLS, colors)):
        upper, lower = axs[:, col]
        grid = np.linspace(0, 130, 2601)
        currents = sum(np.where((grid >= a) & (grid < b), i, 0) for a, b, i in pulses)
        upper.plot(grid, currents, color=color, lw=2)
        upper.set_title(title, fontsize=12, pad=9)
        charge = sum((b - a) * i for a, b, i in pulses) / 1000
        upper.text(.98, .83, f"Total charge: {charge:g} pC", transform=upper.transAxes,
                   ha="right", fontsize=10)
        parts, spikes, holds = segments(pulses)
        for start, stop, initial, equilibrium, _, held in parts:
            t = np.linspace(start, stop, max(2, int((stop - start) * 15)))
            v = np.full_like(t, REST) if held else equilibrium + (initial - equilibrium) * np.exp(-(t - start) / TAU)
            lower.plot(t, v, color=color, lw=2)
        for spike in spikes:
            lower.plot([spike, spike], [THRESHOLD, REST], color=color, lw=1, ls=":")
            lower.plot([spike, spike], [-48.2, -46.5], color=color, lw=2.5)
        for start, stop in holds:
            lower.axvspan(start, stop, color=color, alpha=.12)
        lower.axhline(THRESHOLD, color="#777777", lw=1, ls="--")
        lower.text(.03, .93, f"{len(spikes)} spike event" + ("s" if len(spikes) != 1 else ""),
                   transform=lower.transAxes, fontsize=11, color=color)
        lower.set(xlim=(0, 130), ylim=(-67, -45), xlabel="Time (ms)", xticks=[0, 40, 80, 120])
        upper.set(ylim=(-10, 255), yticks=[0, 100, 200])
        for ax in (upper, lower):
            ax.spines[["top", "right"]].set_visible(False)
            ax.grid(axis="y", color="#eeeeee")
        if col == 1:
            lower.annotate("Threshold event\nat 37.726 ms", xy=(spikes[0], -47),
                           xytext=(62, -54), fontsize=9, arrowprops={"arrowstyle": "->", "color": color})
            lower.text(77, -64.2, "Reset + 2 ms hold", fontsize=9, color=color)
        else:
            peak = max(eq + (v - eq) * np.exp(-(b - a) / TAU) for a, b, v, eq, _, held in parts if not held)
            lower.text(.98, .11, f"Peak: {peak:.4f} mV", transform=lower.transAxes, ha="right", fontsize=9)
    axs[0, 0].set_ylabel("Input current (pA)")
    axs[1, 0].set_ylabel("Membrane potential (mV)")
    axs[1, 2].text(125, -49.6, "Threshold", ha="right", va="bottom", fontsize=9, color="#666666")
    fig.suptitle("Input timing changes spike output", fontsize=17, y=.975)
    fig.text(.5, .895, "Synthetic exact model • no experimental data • ticks are events, not spike waveforms",
             ha="center", fontsize=10)
    fig.text(.5, .025, "R = 100 MΩ    C = 200 pF    τ = 20 ms    Rest/reset = −65 mV    Threshold = −50 mV",
             ha="center", fontsize=10)
    fig.subplots_adjust(left=.085, right=.985, top=.79, bottom=.16, wspace=.15, hspace=.15)
    fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=130,
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()