diff --git a/.gitignore b/.gitignore index f94ec66..13a7db6 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,9 @@ tools/ context/ *.gds.backup + +# paquetes instalados en modo editable (pip install -e) +*.egg-info/ +build/ +dist/ + diff --git a/designs/libs/tb_analog/tb_lif/fixture.py b/designs/libs/tb_analog/tb_lif/fixture.py new file mode 100644 index 0000000..0131ecf --- /dev/null +++ b/designs/libs/tb_analog/tb_lif/fixture.py @@ -0,0 +1,240 @@ +"""Verificacion por simulacion: cierra el lazo diseño -> netlist -> medida. + +En primera instancia el "return" del sistema es un netlist SPICE que se simula +para comprobar que el diseño cumple lo que las leyes predicen. Cuando gLayout +avance, el return sera el layout y esto quedara como verificacion previa. + +Genera el netlist a partir de tb_charac_isrc.spice (entrada de corriente, sin +M6) sustituyendo W_M5, L_M5, Cm y W_M7M8. +""" +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +# lif_design lives in designs/scripts/; add it to the path so this fixture +# can be run straight from the testbench directory, like tb_ota_5t's. +import sys as _sys +from pathlib import Path as _Path +_sys.path.insert(0, str(_Path(__file__).resolve().parents[3] / "scripts")) + +from lif_design import laws as L +from lif_design.spec import NeuronDesign + +# .tran 1n es obligatorio: con 20n la frecuencia se infla +41% de media y hasta +# +193% (el integrador salta ciclos y los cuenta como disparos). +TSTEP_NS = 1 + +# El transitorio debe dar >= 5 ciclos. A frecuencias bajas 30u no basta: una +# neurona a 15 kHz tiene periodo 67us y no completa ni un ciclo. +SKIP_US = 20.0 # arranque que se descarta al medir + + +def _tstop_for(freq_khz: float, min_cycles: int = 8) -> float: + """Transitorio [us] que deja min_cycles DESPUES del arranque. + + Los primeros SKIP_US no cuentan (transitorio de encendido) y measure() + ademas descarta el primer periodo, asi que se piden 2 ciclos de mas. + """ + if freq_khz <= 0: + return 200.0 + return max(30.0, SKIP_US + (min_cycles + 2) * 1000.0 / freq_khz) + + +@dataclass +class VerifyResult: + """Lo medido en la simulacion, frente a lo predicho.""" + ok: bool + measured: dict[str, float] = field(default_factory=dict) + predicted: dict[str, float] = field(default_factory=dict) + errors_pct: dict[str, float] = field(default_factory=dict) + status: str = "" + netlist: str = "" + raw_path: str = "" + + def report(self) -> str: + lines = ["=" * 58, + f"VERIFICACION: {self.status}", + "=" * 58, + f"{'magnitud':<16}{'predicho':>11}{'medido':>11}{'error':>9}"] + for k in self.predicted: + p = self.predicted[k] + m = self.measured.get(k) + if m is None: + lines.append(f"{k:<16}{p:>11.3f}{'--':>11}{'--':>9}") + continue + e = self.errors_pct.get(k, 0.0) + lines.append(f"{k:<16}{p:>11.3f}{m:>11.3f}{e:>8.1f}%") + return "\n".join(lines) + + +def build_netlist(params: dict[str, float], iex_na: float, + template: str | Path, tstop_us: float | None = None, + freq_hint_khz: float = 0.0) -> str: + """Netlist SPICE del diseño, listo para ngspice -b.""" + text = Path(template).read_text(encoding="utf-8", errors="ignore") + W = params["W_M5"] + Lg = params["L_M5"] + Cm = params["Cm"] + w78 = params.get("W_M7M8", 0.22) + + if tstop_us is None: + tstop_us = _tstop_for(freq_hint_khz) + + text = re.sub(r"^IEX 0 iin DC [\d.]+n", f"IEX 0 iin DC {iex_na:g}n", + text, flags=re.M) + text = text.replace("L=50u W=1.25u", f"L={Lg:g}u W={W:g}u") + text = re.sub(r"^C1 integration Vss [\d.]+f", + f"C1 integration Vss {Cm:g}f", text, flags=re.M) + # buffer de salida (M7/M8), solo si se pidio distinto del minimo + if abs(w78 - 0.22) > 1e-9: + text = text.replace( + "XM7 spike spike_neg Vdd Vdd pfet_03v3 L=0.28u W=0.22u", + f"XM7 spike spike_neg Vdd Vdd pfet_03v3 L=0.28u W={w78:g}u") + text = text.replace( + "XM8 spike spike_neg GND GND nfet_03v3 L=0.28u W=0.22u", + f"XM8 spike spike_neg GND GND nfet_03v3 L=0.28u W={w78:g}u") + text = re.sub(r"^\.tran [\d.]+n [\d.]+u", + f".tran {TSTEP_NS}n {tstop_us:g}u", text, flags=re.M) + return text + + +# --- medida sobre el .raw -------------------------------------------------- +def _read_raw(path: str | Path) -> tuple[list[float], list[float], list[float]]: + """(t, v_spike, v_membrana) de un .raw binario de ngspice. + + Sin numpy a proposito: son ~3 columnas y struct.unpack basta. El paquete + de diseño no arrastra dependencias. + """ + import struct + + data = Path(path).read_bytes() + marker = b"Binary:\n" + k = data.find(marker) + if k < 0: + raise ValueError("el .raw no tiene seccion Binary") + header = data[:k].decode("ascii", "ignore") + npts = int([x for x in header.splitlines() + if "No. Points" in x][0].split(":")[1]) + nvar = int([x for x in header.splitlines() + if "No. Variables" in x][0].split(":")[1]) + body = data[k + len(marker):] + vals = struct.unpack(f"<{npts * nvar}d", body[:npts * nvar * 8]) + t = list(vals[0::nvar]) + sp = list(vals[1::nvar]) + vm = list(vals[2::nvar]) if nvar > 2 else [] + return t, sp, vm + + +def measure(raw_path: str | Path, skip_us: float = SKIP_US) -> dict[str, float]: + """Frecuencia, Vth, swing y jitter desde el .raw.""" + t, sp, vm = _read_raw(raw_path) + i0 = next((i for i, x in enumerate(t) if x > skip_us * 1e-6), 0) + t, sp = t[i0:], sp[i0:] + vm = vm[i0:] if vm else [] + if len(t) < 10: + return {"n_cyc": 0} + + # flancos de subida del spike, con umbral a media escala + hi, lo = max(sp), min(sp) + mid = (hi + lo) / 2.0 + edges = [i for i in range(1, len(sp)) + if sp[i - 1] <= mid < sp[i]] + periods = [t[edges[i + 1]] - t[edges[i]] for i in range(len(edges) - 1)] + periods = [p for p in periods if p > 0.2e-6] + if len(periods) > 1: + periods = periods[1:] # descartar el primero, aun transitorio + out: dict[str, float] = {"n_cyc": float(len(periods))} + if periods: + mean = sum(periods) / len(periods) + out["f"] = 1.0 / mean / 1e3 # kHz + if len(periods) > 1: + var = sum((p - mean) ** 2 for p in periods) / len(periods) + out["jitter_pct"] = 100.0 * (var ** 0.5) / mean + else: + out["jitter_pct"] = 0.0 + if vm: + out["Vth"] = max(vm) + out["Vm_min"] = min(vm) + out["swing"] = max(vm) - min(vm) + out["v_out_swing"] = hi - lo + return out + + +# --- verificacion completa ------------------------------------------------- +def verify(design_result: NeuronDesign, iex_na: float = L.IEX_REF, + workdir: str | Path = ".", template: str | Path | None = None, + ngspice: str = "ngspice", timeout_s: int = 900, + keep_files: bool = True) -> VerifyResult: + """Simula el diseño y compara lo medido con lo predicho. + + workdir debe ser la carpeta del testbench (tb/), porque el netlist usa + rutas relativas para el .raw. + """ + workdir = Path(workdir) + if template is None: + template = workdir / "tb_lif.spice" + + p = design_result.params + W, Lg, Cm = p["W_M5"], p["L_M5"], p["Cm"] + f_pred = L.freq(W, Lg, iex_na) + + tag = f"verify_{W:g}_{Lg:g}_{Cm:g}_{iex_na:g}".replace(".", "p") + raw_rel = f"{tag}.raw" + netlist = build_netlist(p, iex_na, template, freq_hint_khz=f_pred) + netlist = netlist.replace("tb_charac_isrc.raw", raw_rel) + + spice_path = workdir / f"{tag}.spice" + spice_path.write_text(netlist, encoding="utf-8", newline="\n") + + res = VerifyResult(ok=False, netlist=netlist, + raw_path=str(workdir / raw_rel)) + res.predicted = { + "f": round(f_pred, 1), + "Vth": round(L.vth(W, Lg, Cm), 3), + "swing": round(L.swing(W, Lg, Cm), 3), + } + + try: + subprocess.run([ngspice, "-b", spice_path.name], cwd=str(workdir), + capture_output=True, timeout=timeout_s, check=False) + except FileNotFoundError: + res.status = f"ngspice no encontrado ({ngspice})" + return res + except subprocess.TimeoutExpired: + res.status = f"la simulacion supero {timeout_s} s" + return res + + raw_file = workdir / raw_rel + if not raw_file.exists(): + res.status = "la simulacion no genero .raw" + return res + + res.measured = measure(raw_file) + if not keep_files: + spice_path.unlink(missing_ok=True) + raw_file.unlink(missing_ok=True) + + if res.measured.get("n_cyc", 0) < 3: + res.status = "NO OSCILA (menos de 3 ciclos)" + return res + + for k, pv in res.predicted.items(): + mv = res.measured.get(k) + if mv: + res.errors_pct[k] = 100.0 * (pv - mv) / mv + + worst = max((abs(v) for v in res.errors_pct.values()), default=0.0) + vmin = res.measured.get("Vm_min", 0.0) + if vmin < -0.05: + res.status = f"ANOMALO: la membrana baja a {vmin:.3f} V" + elif res.measured.get("jitter_pct", 0) > 2.0: + res.status = f"NO CONVERGIO (jitter {res.measured['jitter_pct']:.1f}%)" + elif worst <= 10.0: + res.ok = True + res.status = f"OK (peor error {worst:.1f}%)" + else: + res.status = f"DESVIACION ALTA (peor error {worst:.1f}%)" + return res diff --git a/designs/libs/tb_analog/tb_lif/sweep_m5_switch.py b/designs/libs/tb_analog/tb_lif/sweep_m5_switch.py new file mode 100644 index 0000000..3ca6943 --- /dev/null +++ b/designs/libs/tb_analog/tb_lif/sweep_m5_switch.py @@ -0,0 +1,120 @@ +"""Recaracterizacion con M5 como interruptor. + +Las leyes actuales se ajustaron con M5 largo (L=50um), donde la carga que el +interruptor inyecta al abrirse domina el umbral: Vth = 1.2792 + Q/Cm con +Q proporcional a W*L. Eso convierte a M5 en el mando de frecuencia y obliga a +canales de 20-50um, que en layout son una barra de 24-54um de ancho. + +Con M5 al minimo la carga inyectada cae de ~114 fC a ~0.4 fC, el umbral +colapsa al punto de conmutacion del inversor y la frecuencia deberia quedar + + f = Iex / (Cm * dV) con dV fijo + +o sea proporcional a Iex e inversa a Cm, sin que W ni L intervengan. Este +barrido comprueba exactamente eso, y de paso mide cuanta frecuencia mueve M5 +todavia -- si mueve poco, deja de ser variable de diseño. + +Uso: python sweep_m5_switch.py [salida.csv] +""" +from __future__ import annotations + +import subprocess +import sys +import tempfile +from pathlib import Path + +import fixture as fx + +AQUI = Path(__file__).resolve().parent +PLANTILLA = AQUI / "tb_lif.spice" + +# dV esperado si la inyeccion es despreciable: el punto de conmutacion del +# inversor, que ya sale como termino independiente de la ley de Vth. +DV_ESPERADO = 1.2792 + +M5_MIN = (0.22, 0.28) # W, L en um -- minimo construible (dogbone) +M5_ABE = (10.0, 0.28) # lo que puso Abrahan +M5_VIEJO = (1.25, 50.0) # el del barrido original + +# Malla principal: M5 minimo, se mueven Cm e Iex. La corriente sube con Cm +# para que el periodo no se dispare y el transitorio quede acotado. +MALLA = [(M5_MIN, Cm, iex) + for Cm in (50.0, 100.0, 150.0, 300.0) + for iex in (100.0, 200.0, 400.0)] + +# Control del mecanismo: mismo Cm e Iex, tres tamaños de M5. Si la frecuencia +# apenas se mueve entre el minimo y el de Abrahan, la inyeccion ya no manda. +CONTROL = [(m5, 150.0, 100.0) for m5 in (M5_MIN, M5_ABE, M5_VIEJO)] + + +def periodo_esperado_us(cm_ff: float, iex_na: float) -> float: + """T = Cm*dV/Iex, en microsegundos.""" + return cm_ff * 1e-15 * DV_ESPERADO / (iex_na * 1e-9) * 1e6 + + +def corre(m5, Cm, iex, etiqueta): + w, l = m5 + t_per = periodo_esperado_us(Cm, iex) + # 6 periodos utiles mas el arranque que measure() descarta + tstop = max(8.0, fx.SKIP_US + 8 * t_per) + params = {"W_M5": w, "L_M5": l, "Cm": Cm} + netlist = fx.build_netlist(params, iex, PLANTILLA, tstop_us=tstop) + + with tempfile.TemporaryDirectory() as d: + deck = Path(d) / "tb.spice" + raw = Path(d) / "tb_charac_isrc.raw" + deck.write_text(netlist, encoding="utf-8") + proc = subprocess.run(["ngspice", "-b", str(deck)], cwd=d, + capture_output=True, text=True, timeout=7200) + if not raw.exists(): + cola = (proc.stderr or proc.stdout or "").strip().splitlines()[-1:] + return {"error": " ".join(cola) or "sin raw"} + m = fx.measure(str(raw)) + + f_ideal = iex / (Cm * DV_ESPERADO) * 1e3 # kHz + m.update({"W_M5": w, "L_M5": l, "Cm": Cm, "iex": iex, + "tstop_us": tstop, "f_ideal": f_ideal, "etiqueta": etiqueta}) + return m + + +def linea(r): + if "error" in r: + return ("%-9s W=%-5s L=%-5s Cm=%-6s Iex=%-5s FALLO: %s" + % (r.get("etiqueta", ""), r.get("W_M5"), r.get("L_M5"), + r.get("Cm"), r.get("iex"), r["error"][:60])) + f = r.get("f", 0.0) + err = 100 * (f - r["f_ideal"]) / r["f_ideal"] if r["f_ideal"] else 0.0 + return ("%-9s W=%-5.2f L=%-5.2f Cm=%-6.0f Iex=%-5.0f f=%8.1f " + "(ideal %8.1f, %+6.1f%%) Vth=%5.2f swing=%5.2f ciclos=%2.0f" + % (r["etiqueta"], r["W_M5"], r["L_M5"], r["Cm"], r["iex"], + f, r["f_ideal"], err, r.get("Vth", 0.0), r.get("swing", 0.0), + r.get("n_cyc", 0))) + + +def main(destino=None): + filas = [] + print("CONTROL DEL MECANISMO -- mismo Cm e Iex, distinto M5") + for m5, Cm, iex in CONTROL: + r = corre(m5, Cm, iex, "control") + filas.append(r) + print(" " + linea(r), flush=True) + + print("\nMALLA -- M5 minimo, se mueven Cm e Iex") + for m5, Cm, iex in MALLA: + r = corre(m5, Cm, iex, "malla") + filas.append(r) + print(" " + linea(r), flush=True) + + if destino: + campos = ["etiqueta", "W_M5", "L_M5", "Cm", "iex", "f", "f_ideal", + "Vth", "Vm_min", "swing", "jitter_pct", "n_cyc", "tstop_us"] + with open(destino, "w", encoding="utf-8") as fh: + fh.write(",".join(campos) + "\n") + for r in filas: + fh.write(",".join(str(r.get(c, "")) for c in campos) + "\n") + print(f"\nescrito {destino}") + return filas + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else None) diff --git a/designs/libs/tb_analog/tb_lif/tb_lif.spice b/designs/libs/tb_analog/tb_lif/tb_lif.spice new file mode 100644 index 0000000..847418c --- /dev/null +++ b/designs/libs/tb_analog/tb_lif/tb_lif.spice @@ -0,0 +1,44 @@ +* Caracterizacion LIF - ENTRADA DE CORRIENTE +* +* La celda de Abrahan usa entrada de corriente (se conecta a distintas etapas), +* asi que NO lleva M6 (el espejo PMOS que convertia Vin -> Iex). La corriente +* entra directamente al nodo de membrana. +* +* Diferencia con tb_charac.spice: sin XM6, sin V2/Vin, y una fuente IEX que +* inyecta corriente en 'integration'. La ley Iex = 169.1*(2.571-Vin)^2 describe +* un bloque que ahora vive FUERA de esta celda. +.include /foss/pdks/gf180mcuD/libs.tech/ngspice/design.ngspice +.lib /foss/pdks/gf180mcuD/libs.tech/ngspice/sm141064.ngspice typical +.subckt neurona Vdd Vss iin spike spike_neg +XM1 spike_neg integration Vdd Vdd pfet_03v3 L=0.28u W=0.22u nf=1 ad='int((nf+1)/2) * W/nf * 0.18u' as='int((nf+2)/2) * W/nf * 0.18u' ++ pd='2*int((nf+1)/2) * (W/nf + 0.18u)' ps='2*int((nf+2)/2) * (W/nf + 0.18u)' nrd='0.18u / W' nrs='0.18u / W' sa=0 sb=0 sd=0 +XM2 spike_neg integration Vss Vss nfet_03v3 L=0.28u W=0.22u nf=1 ad='int((nf+1)/2) * W/nf * 0.18u' as='int((nf+2)/2) * W/nf * 0.18u' ++ pd='2*int((nf+1)/2) * (W/nf + 0.18u)' ps='2*int((nf+2)/2) * (W/nf + 0.18u)' nrd='0.18u / W' nrs='0.18u / W' sa=0 sb=0 sd=0 +XM3 spike/reset spike_neg Vdd Vdd pfet_03v3 L=0.28u W=0.22u nf=1 ad='int((nf+1)/2) * W/nf * 0.18u' as='int((nf+2)/2) * W/nf * 0.18u' ++ pd='2*int((nf+1)/2) * (W/nf + 0.18u)' ps='2*int((nf+2)/2) * (W/nf + 0.18u)' nrd='0.18u / W' nrs='0.18u / W' sa=0 sb=0 sd=0 +XM4 spike/reset spike_neg Vss Vss nfet_03v3 L=0.28u W=0.22u nf=1 ad='int((nf+1)/2) * W/nf * 0.18u' as='int((nf+2)/2) * W/nf * 0.18u' ++ pd='2*int((nf+1)/2) * (W/nf + 0.18u)' ps='2*int((nf+2)/2) * (W/nf + 0.18u)' nrd='0.18u / W' nrs='0.18u / W' sa=0 sb=0 sd=0 +XM7 spike spike_neg Vdd Vdd pfet_03v3 L=0.28u W=0.22u nf=1 ad='int((nf+1)/2) * W/nf * 0.18u' as='int((nf+2)/2) * W/nf * 0.18u' pd='2*int((nf+1)/2) * (W/nf + 0.18u)' ++ ps='2*int((nf+2)/2) * (W/nf + 0.18u)' nrd='0.18u / W' nrs='0.18u / W' sa=0 sb=0 sd=0 +XM8 spike spike_neg GND GND nfet_03v3 L=0.28u W=0.22u nf=1 ad='int((nf+1)/2) * W/nf * 0.18u' as='int((nf+2)/2) * W/nf * 0.18u' pd='2*int((nf+1)/2) * (W/nf + 0.18u)' ++ ps='2*int((nf+2)/2) * (W/nf + 0.18u)' nrd='0.18u / W' nrs='0.18u / W' sa=0 sb=0 sd=0 +XM5 integration spike/reset Vss Vss nfet_03v3 L=50u W=1.25u nf=1 ad='int((nf+1)/2) * W/nf * 0.18u' as='int((nf+2)/2) * W/nf * 0.18u' ++ pd='2*int((nf+1)/2) * (W/nf + 0.18u)' ps='2*int((nf+2)/2) * (W/nf + 0.18u)' nrd='0.18u / W' nrs='0.18u / W' sa=0 sb=0 sd=0 +C1 integration Vss 150f +* la corriente de entrada llega directo al nodo de membrana +Riin iin integration 0.001 +.ends +.GLOBAL GND +.end + +Vdd Vdd 0 3.3 +Vss Vss 0 0 +IEX 0 iin DC 100n +X1 Vdd Vss iin spike spike_neg neurona +.tran 1n 30u +.control +save v(spike) v(x1.integration) +run +write tb_charac_isrc.raw v(spike) v(x1.integration) +.endc +.end diff --git a/designs/libs/tb_analog/tb_lif/test_all.py b/designs/libs/tb_analog/tb_lif/test_all.py new file mode 100644 index 0000000..6a02b10 --- /dev/null +++ b/designs/libs/tb_analog/tb_lif/test_all.py @@ -0,0 +1,79 @@ +"""pytest entry point for the LIF neuron testbench. + +Mirrors tb_ota_5t's shape: compose a netlist, run ngspice, assert on what +comes back. The reference values are the characterisation laws in +designs/scripts/lif_design, so a failure means the cell no longer behaves +like the model its dimensions were picked from. + + cd designs/libs/tb_analog/tb_lif && pytest -v +""" +import sys +from pathlib import Path + +import pytest + +from fixture import L, verify + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts")) +from lif_design.solver import design # noqa: E402 +from lif_design.spec import NeuronSpec # noqa: E402 + + +TB_DIR = Path(__file__).resolve().parent + +# Nominal design point — see designs/scripts/lif_design/README.md. +F_TARGET_KHZ = 500.0 +IEX_NA = 100.0 + +# The laws fit the measured sweep to ~2% RMS. 10% leaves room for simulator +# spread without letting a real regression through. +TOL_PCT = 10.0 + + +def _spec(): + """Single operating point: a degenerate freq_range pins one frequency. + + iex_range is left out on purpose -- giving both as zero-width ranges asks + the solver for a gain of 0/0. + """ + return NeuronSpec(freq_range=(F_TARGET_KHZ, F_TARGET_KHZ)) + + +@pytest.fixture(scope="module") +def result(): + """Dimension the neuron for F_TARGET_KHZ, then simulate it once.""" + return verify(design(_spec()), iex_na=IEX_NA, workdir=TB_DIR) + + +def test_oscillates(result): + """Fewer than three cycles means the membrane never really fires.""" + assert result.measured.get("n_cyc", 0) >= 3, result.status + + +def test_frequency(result): + """Spike rate tracks f[kHz] = 24837 * W^-1.076 * L^-0.940 * (Iex/100nA).""" + err = abs(result.errors_pct.get("f", 100.0)) + assert err < TOL_PCT, ( + f"freq off by {err:.1f}%: measured {result.measured.get('f')} kHz, " + f"predicted {result.predicted.get('f')} kHz") + + +def test_swing(result): + """Membrane swing tracks swing = 4.114 * W^0.951 * L^1.065 * Cm^-1.006.""" + err = abs(result.errors_pct.get("swing", 100.0)) + assert err < TOL_PCT, ( + f"swing off by {err:.1f}%: measured {result.measured.get('swing')} V, " + f"predicted {result.predicted.get('swing')} V") + + +def test_membrane_stays_positive(result): + """Vm dipping below ground means the integrator is being over-driven.""" + vmin = result.measured.get("Vm_min", 0.0) + assert vmin > -0.05, f"membrane reaches {vmin:.3f} V" + + +def test_cm_above_floor(): + """Cm must clear the oscillation floor Cm_min = 8.94 * W^1.038 * L^0.700.""" + p = design(_spec()).params + floor = L.Cm_min(p["W_M5"], p["L_M5"]) + assert p["Cm"] > floor, f"Cm {p['Cm']:.1f} fF is under the {floor:.1f} fF floor" diff --git a/designs/notebooks/3_test_lif_engine.ipynb b/designs/notebooks/3_test_lif_engine.ipynb new file mode 100644 index 0000000..4eaaa2c --- /dev/null +++ b/designs/notebooks/3_test_lif_engine.ipynb @@ -0,0 +1,1182 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "094f442f", + "metadata": {}, + "source": [ + "# Motor de diseño de la neurona LIF\n", + "\n", + "De una especificación de comportamiento a un layout verificado.\n", + "\n", + "El motor tiene dos capas y este notebook las recorre por separado para que se vea\n", + "dónde acaba una y empieza la otra:\n", + "\n", + "1. **Caracterización** — de lo que pides (frecuencia, corriente de entrada) a las\n", + " dimensiones de los dispositivos. Cinco leyes ajustadas sobre simulación.\n", + "2. **Layout** — de esas dimensiones a un GDS: placement por bandas, ruteo, rieles.\n", + "\n", + "Y dos verificaciones, porque una sola no basta: el DRC dice si las formas son legales,\n", + "no si están conectadas.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "37127227", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:05.569961Z", + "iopub.status.busy": "2026-08-27T19:04:05.569306Z", + "iopub.status.idle": "2026-08-27T19:04:05.947315Z", + "shell.execute_reply": "2026-08-27T19:04:05.943334Z" + } + }, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e9f8c9dc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:05.953710Z", + "iopub.status.busy": "2026-08-27T19:04:05.953139Z", + "iopub.status.idle": "2026-08-27T19:04:07.400202Z", + "shell.execute_reply": "2026-08-27T19:04:07.396369Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "deck de DRC: /tmp/glayout/src/glayout/pdk/gf180_mapped/gf180mcu.drc\n", + "salida: /tmp/nbout\n" + ] + } + ], + "source": [ + "import pathlib\n", + "import sys\n", + "\n", + "# el paquete vive en designs/scripts/lif_design; se busca subiendo desde aqui\n", + "raiz = next(p for p in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]\n", + " if (p / 'designs' / 'scripts' / 'lif_design').exists())\n", + "sys.path.insert(0, str(raiz / 'designs' / 'scripts'))\n", + "\n", + "from glayout import gf180\n", + "gf180.activate()\n", + "\n", + "from lif_design.spec import NeuronSpec\n", + "from lif_design.solver import design\n", + "from lif_design.build import from_design\n", + "from lif_design import mim, check\n", + "\n", + "print('deck de DRC:', check.DECK)\n", + "print('salida: ', check.SALIDA)\n" + ] + }, + { + "cell_type": "markdown", + "id": "4ee3a12d", + "metadata": {}, + "source": [ + "## Capa 1 — de la intención a las dimensiones\n", + "\n", + "`NeuronSpec` es lo que pides. Todo es opcional: `None` significa *decide tú*, que no es\n", + "lo mismo que un valor por defecto — es lo que le da libertad al solver.\n", + "\n", + "`design()` **nunca lanza una excepción**. Siempre devuelve la mejor solución alcanzable\n", + "más una lista de notas explicando qué decidió, qué cambió y por qué. Si pides algo\n", + "imposible te lo dice con la cadena causal, no con un stack trace.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6a06e752", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:07.405559Z", + "iopub.status.busy": "2026-08-27T19:04:07.404798Z", + "iopub.status.idle": "2026-08-27T19:04:07.595094Z", + "shell.execute_reply": "2026-08-27T19:04:07.590394Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "resoluble: True\n", + "\n", + "dimensiones que salen:\n", + " W_M5 1.67\n", + " L_M5 35.42\n", + " Cm 222.00\n", + " W_M7M8 0.22\n", + "\n", + "comportamiento predicho:\n", + " k [kHz/nA] 4.756\n", + " f a 100 nA [kHz] 499.9\n", + " f en el rango [kHz] (499.9, 499.9)\n", + " Vth [V] 1.701\n", + " swing [V] 1.306\n", + " Cm_min [fF] 185.0\n", + " ventana Iex [nA] (5.0, 900.2)\n", + " C_in [fF] 2.39\n", + " C_load max [fF] 132.0\n", + "\n", + "[INFO] frecuencia: objetivo puntual: 500.0 kHz a 100.0 nA\n", + "[INFO] geometria: W=1.671 L=35.4 elegidas por margen de validez (habia una familia de soluciones sobre la curva de iso-frecuencia)\n", + "[INFO] Cm: sin objetivo de Vth; se usa 1.2 x Cm_min = 222 fF (margen sobre el limite de operacion)\n" + ] + } + ], + "source": [ + "d = design(NeuronSpec(freq_range=500, iex_range=100))\n", + "\n", + "print('resoluble:', d.ok)\n", + "print()\n", + "print('dimensiones que salen:')\n", + "for k, v in d.params.items():\n", + " print(' %-8s %8.2f' % (k, v))\n", + "print()\n", + "print('comportamiento predicho:')\n", + "for k, v in d.predicted.items():\n", + " print(' %-14s %s' % (k, v))\n", + "print()\n", + "for n in d.notes:\n", + " print(n)\n" + ] + }, + { + "cell_type": "markdown", + "id": "05107908", + "metadata": {}, + "source": [ + "### Qué pasa cuando pides algo que no se puede\n", + "\n", + "Los objetivos mandan sobre las dimensiones fijadas. Si fijas una dimensión que estorba,\n", + "se ajusta y te avisa; sólo cuando la contradicción no tiene salida sale error.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "d92a3761", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:07.600727Z", + "iopub.status.busy": "2026-08-27T19:04:07.600113Z", + "iopub.status.idle": "2026-08-27T19:04:07.661579Z", + "shell.execute_reply": "2026-08-27T19:04:07.658542Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "resoluble: True Cm final: 174.3 fF\n", + "[WARNING] Cm: subida de 52 a 174 fF: bajo Cm_min la membrana sale del riel\n", + " cadena: Cm_min(W=1.25, L=50.0) = 174 fF\n" + ] + } + ], + "source": [ + "apretado = design(NeuronSpec(Cm=52, iex_range=100))\n", + "print('resoluble:', apretado.ok, ' Cm final: %.1f fF' % apretado.params['Cm'])\n", + "for n in apretado.notes:\n", + " print(n)\n" + ] + }, + { + "cell_type": "markdown", + "id": "298fca18", + "metadata": {}, + "source": [ + "### Las tres clases de entrada\n", + "\n", + "`NeuronSpec` no tiene modos ni variantes: tiene tres clases de campo, y cualquier\n", + "combinación es válida.\n", + "\n", + "| clase | campos | qué hacen |\n", + "|---|---|---|\n", + "| **objetivos** | `freq_range`, `iex_range`, `vth`, `c_load` | mandan sobre todo lo demás |\n", + "| **dimensiones fijadas** | `W_M5`, `L_M5`, `Cm`, `W_M7M8` | se respetan mientras no estorben |\n", + "| **contexto** | `source_ro`, `freq_tolerance` | no dirigen la solución, acotan el error |\n", + "\n", + "Los rangos aceptan un par o un número suelto, y esa diferencia **cambia el problema**:\n", + "con `iex_range` fijo se persigue un punto de operación, y con un rango se persigue una\n", + "*pendiente* — la ganancia `k` en kHz/nA. No es lo mismo pedir 500 kHz que pedir que la\n", + "neurona recorra de 200 a 800 kHz mientras la corriente va de 50 a 200 nA." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ee8e8f32", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:07.668974Z", + "iopub.status.busy": "2026-08-27T19:04:07.668413Z", + "iopub.status.idle": "2026-08-27T19:04:07.735388Z", + "shell.execute_reply": "2026-08-27T19:04:07.731560Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "lo que pides W_M5 L_M5 Cm avisos\n", + "------------------------------------------------------------------------------\n", + "frecuencia exacta 1.67 35.42 222.0 -\n", + "rango de frecuencia 1.41 35.42 186.2 -\n", + "rango en ambos: ganancia 2.06 35.42 275.3 -\n", + "L fijada, W libre 1.50 40.00 216.5 -\n", + "objetivo de Vth 1.24 50.00 510.7 -\n", + "con carga de salida 1.67 35.42 222.0 -\n" + ] + } + ], + "source": [ + "casos = [\n", + " ('frecuencia exacta', NeuronSpec(freq_range=500, iex_range=100)),\n", + " ('rango de frecuencia', NeuronSpec(freq_range=(400, 600), iex_range=100)),\n", + " ('rango en ambos: ganancia',NeuronSpec(freq_range=(200, 800), iex_range=(50, 200))),\n", + " ('L fijada, W libre', NeuronSpec(freq_range=500, iex_range=100, L_M5=40.0)),\n", + " ('objetivo de Vth', NeuronSpec(freq_range=500, iex_range=100, vth=1.5)),\n", + " ('con carga de salida', NeuronSpec(freq_range=500, iex_range=100, c_load=80.0)),\n", + "]\n", + "\n", + "print('%-26s %7s %7s %8s %s' % ('lo que pides', 'W_M5', 'L_M5', 'Cm', 'avisos'))\n", + "print('-' * 78)\n", + "for nombre, s in casos:\n", + " d = design(s)\n", + " avisos = [n for n in d.notes if n.severity != 'info']\n", + " print('%-26s %7.2f %7.2f %8.1f %s' % (\n", + " nombre, d.params['W_M5'], d.params['L_M5'], d.params['Cm'],\n", + " '%d' % len(avisos) if avisos else '-'))" + ] + }, + { + "cell_type": "markdown", + "id": "0ef4447b", + "metadata": {}, + "source": [ + "### Cuando lo que pides se contradice\n", + "\n", + "Aquí es donde se ve la prioridad. Se fijan `W` y `L` **y** se pide una frecuencia que\n", + "esas dos dimensiones no dan. El motor no elige por su cuenta ni aborta: enseña las dos\n", + "salidas que evaluó, dice cuál descartó y por qué, y ajusta lo mínimo." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2668d44e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:07.741828Z", + "iopub.status.busy": "2026-08-27T19:04:07.740619Z", + "iopub.status.idle": "2026-08-27T19:04:07.811328Z", + "shell.execute_reply": "2026-08-27T19:04:07.807093Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "resoluble: True\n", + " W_M5 2.75\n", + " L_M5 20.00\n", + " Cm 249.90\n", + " W_M7M8 0.22\n", + "\n", + "[INFO] frecuencia: objetivo puntual: 500.0 kHz a 100.0 nA\n", + "[WARNING] frecuencia: W=3.0 y L=25.0 fijas dan 370 kHz, no 500. Los objetivos tienen prioridad, asi que se ajustan las dimensiones\n", + " cadena: liberar L_M5 -> exigiria L=18.1 um (fuera de 20.0-50.0) | liberar W_M5 -> W=2.265 um\n", + "[WARNING] W_M5: cambiada de 3.0 a 2.753 um; L_M5 tambien a 20.0 um\n", + "[INFO] Cm: sin objetivo de Vth; se usa 1.2 x Cm_min = 250 fF (margen sobre el limite de operacion)\n", + "[WARNING] L_M5: 20.0 um: bajo 25.0 um el error de la ley de frecuencia sube de ~1% a 5-7%\n" + ] + } + ], + "source": [ + "d = design(NeuronSpec(freq_range=500, iex_range=100, W_M5=3.0, L_M5=25.0))\n", + "\n", + "print('resoluble:', d.ok)\n", + "for k, v in d.params.items():\n", + " print(' %-8s %8.2f' % (k, v))\n", + "print()\n", + "for n in d.notes:\n", + " print(n)" + ] + }, + { + "cell_type": "markdown", + "id": "10ca7964", + "metadata": {}, + "source": [ + "Y un límite que no es de las leyes sino del silicio: sobre `F_MAX = 4500 kHz` el reset no\n", + "llega a completarse. Las leyes despejan igual y devuelven una geometría de aspecto\n", + "razonable, así que sin el aviso el motor entregaría en silencio una celda que no dispara." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "3dcdfddb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:07.817779Z", + "iopub.status.busy": "2026-08-27T19:04:07.817235Z", + "iopub.status.idle": "2026-08-27T19:04:07.890051Z", + "shell.execute_reply": "2026-08-27T19:04:07.881776Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " 2000 kHz -> dentro de rango\n", + " 4500 kHz -> dentro de rango\n", + " 6000 kHz -> 6000 kHz esta sobre el maximo medido (4500 kHz): la geometria sale de las leyes, pero el reset no completa y la celda no llegara a esa frecuencia\n" + ] + } + ], + "source": [ + "for f in (2000, 4500, 6000):\n", + " d = design(NeuronSpec(freq_range=f, iex_range=100))\n", + " avisos = [n for n in d.notes if n.severity != 'info']\n", + " print('%5d kHz -> %s' % (f, avisos[0].message if avisos else 'dentro de rango'))" + ] + }, + { + "cell_type": "markdown", + "id": "89af7628", + "metadata": {}, + "source": [ + "### El contexto: la fuente de corriente no es ideal\n", + "\n", + "Las cinco leyes se midieron sobre `tb_charac_isrc.spice`, con una **fuente de corriente\n", + "ideal en lugar de M6** — el transistor de entrada. Eso no es un descuido: en este diseño\n", + "M6 no vive en la neurona, vive en el **encoder**, que entrega cuatro corrientes\n", + "(`Iex_1_i` … `Iex_4_i`) desde sendos pfets de espejo.\n", + "\n", + "Una fuente ideal tiene impedancia de salida infinita; un pfet real, no. `source_ro`\n", + "existe para cuantificar esa diferencia en vez de ignorarla. El error va como `1/ro`:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "a071d361", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:07.897618Z", + "iopub.status.busy": "2026-08-27T19:04:07.896926Z", + "iopub.status.idle": "2026-08-27T19:04:07.978399Z", + "shell.execute_reply": "2026-08-27T19:04:07.973361Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "el error va como 1/ro:\n", + "\n", + " con ro=5 MOhm el error de frecuencia sera ~380.0%\n", + " con ro=20 MOhm el error de frecuencia sera ~95.0%\n", + " con ro=100 MOhm el error de frecuencia sera ~19.0%\n", + " con ro=200 MOhm el error de frecuencia sera ~9.5%\n", + " con ro=500 MOhm el error de frecuencia sera ~3.8%\n", + " con ro=1000 MOhm el error de frecuencia sera ~1.9%\n", + "\n", + "para bajar del 5% hace falta ro >= ~400 MOhm\n" + ] + } + ], + "source": [ + "print('el error va como 1/ro:')\n", + "print()\n", + "for ro in (5e6, 2e7, 1e8, 2e8, 5e8, 1e9):\n", + " d = design(NeuronSpec(freq_range=500, iex_range=100, source_ro=ro))\n", + " nota = next((n for n in d.notes if n.subject == 'fuente'), None)\n", + " print(' ', nota.message if nota else 'sin aviso')\n", + "\n", + "print()\n", + "print('para bajar del 5% hace falta ro >= ~400 MOhm')\n" + ] + }, + { + "cell_type": "markdown", + "id": "264f0136", + "metadata": {}, + "source": [ + "### Lo que la celda le ofrece a la etapa previa\n", + "\n", + "`source_ro` es lo que le **exigimos** a la fuente. `C_in` es lo que le\n", + "**ofrecemos**: la capacidad que ve colgando del nodo, aparte de `Cm`.\n", + "\n", + "Es el dual de `c_load`. Nuestro `c_load` es el `C_in` de la celda siguiente, y\n", + "nuestro `C_in` es el `c_load` de la anterior; cuando el generador encadene\n", + "codificador -> LIF -> sinapsis, este numero es el que viaja hacia arriba.\n", + "\n", + "Se midio como `C_total - Cm`, con `C_total = Iex/(dV/dt)` sobre la rampa de\n", + "integracion: la propia fuente cargando el nodo *es* la medida. Sale afin y solo\n", + "en `W_M5` -- el termino constante son las puertas de M1/M2, que cuelgan del\n", + "nodo aunque M5 sea minimo, y el lineal es la union de drenador de M5.\n", + "\n", + "**No se suma a `f`.** La ley de frecuencia se ajusto sobre simulaciones del\n", + "circuito completo que ya lo contienen; sumarlo otra vez lo contaria dos veces." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "55860a98", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:07.983617Z", + "iopub.status.busy": "2026-08-27T19:04:07.982972Z", + "iopub.status.idle": "2026-08-27T19:04:08.052018Z", + "shell.execute_reply": "2026-08-27T19:04:08.048581Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "f pedida W_M5 Cm C_in C_in/Cm\n", + "------------------------------------------------\n", + " 200 kHz 3.176 511.4 3.69 0.72%\n", + " 500 kHz 1.671 222.0 2.39 1.08%\n", + " 1000 kHz 0.877 113.8 1.70 1.49%\n", + " 2000 kHz 0.461 60.0 1.34 2.23%\n", + " 4000 kHz 0.273 60.0 1.18 1.97%\n" + ] + } + ], + "source": [ + "print('%-10s %7s %9s %7s %9s' % ('f pedida', 'W_M5', 'Cm', 'C_in', 'C_in/Cm'))\n", + "print('-' * 48)\n", + "for f in (200, 500, 1000, 2000, 4000):\n", + " d = design(NeuronSpec(freq_range=f, iex_range=100))\n", + " ci, cm = d.predicted['C_in [fF]'], d.params['Cm']\n", + " print('%7d kHz %7.3f %9.1f %7.2f %8.2f%%'\n", + " % (f, d.params['W_M5'], cm, ci, 100 * ci / cm))" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "683943fc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:08.056676Z", + "iopub.status.busy": "2026-08-27T19:04:08.056331Z", + "iopub.status.idle": "2026-08-27T19:04:08.117152Z", + "shell.execute_reply": "2026-08-27T19:04:08.112825Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "c_in_max = 3.0 fF -> lo cumple, sin aviso\n", + "c_in_max = 1.2 fF -> el diseño presenta C_in=1.47 fF a la etapa previa, sobre el maximo pedido de 1.20 fF; haria falta W_M5 <= 0.295 um\n" + ] + } + ], + "source": [ + "# c_in_max se comprueba, no se resuelve. C_in solo va de 1.1 a 4.0 fF en todo\n", + "# el envolvente, asi que la cota casi nunca puede morder; resolverla gastaria\n", + "# W_M5, que es el mando mas caro de la cadena de ajuste.\n", + "for tope in (3.0, 1.2):\n", + " d = design(NeuronSpec(freq_range=1500, iex_range=100, c_in_max=tope))\n", + " avisos = [n for n in d.notes if n.subject == 'C_in']\n", + " print('c_in_max = %.1f fF -> %s'\n", + " % (tope, avisos[0].message if avisos else 'lo cumple, sin aviso'))" + ] + }, + { + "cell_type": "markdown", + "id": "55437ef6", + "metadata": {}, + "source": [ + "## Capa 2 — de las dimensiones al layout\n", + "\n", + "`from_design()` es la unión entre las dos capas. Devuelve el componente, los handles de\n", + "cada bloque (para poder sondearlos después) y sus propias notas: las de esta capa hablan\n", + "de lo que se pierde al pasar de un número continuo a geometría.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "54498bf5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:08.122619Z", + "iopub.status.busy": "2026-08-27T19:04:08.122206Z", + "iopub.status.idle": "2026-08-27T19:04:09.570279Z", + "shell.execute_reply": "2026-08-27T19:04:09.567292Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "caja: 40.73 x 25.95 um = 1057 um2\n", + "esquina inferior izquierda: (0.000, 0.000)\n", + "bloques: {'nfets': 3, 'pfets': 3, 'caps': 3, 'm5': 1}\n", + "\n", + "[INFO] Cm: pedida 222.0 fF -> 3 MIM de 5.870 um de lado = 222.5 fF (+0.2%), con cap_mim_2f0_m4m5_noshield\n", + " cadena: snap a rejilla del lado del MIM\n" + ] + }, + { + "data": { + "text/plain": [ + "'/tmp/nbout/lif_demo.gds'" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# El ejemplo canonico, el mismo del README: 500 kHz a 100 nA. Se resuelve\n", + "# aqui en vez de heredar `d`, que para este punto ya paso por siete barridos\n", + "# y traia el ultimo -- 1500 kHz con tope de c_in -- dibujando una celda que\n", + "# no era la que el texto describe.\n", + "d_demo = design(NeuronSpec(freq_range=500, iex_range=100))\n", + "celda, h, notas = from_design(gf180, d_demo, name='lif_demo')\n", + "\n", + "bb = celda.bbox\n", + "ancho, alto = bb[1][0] - bb[0][0], bb[1][1] - bb[0][1]\n", + "print('caja: %.2f x %.2f um = %.0f um2' % (ancho, alto, ancho * alto))\n", + "print('esquina inferior izquierda: (%.3f, %.3f)' % (bb[0][0], bb[0][1]))\n", + "print('bloques:', {k: (len(v) if isinstance(v, list) else 1)\n", + " for k, v in h.items() if k in ('nfets', 'pfets', 'caps', 'm5')})\n", + "print()\n", + "for n in notas:\n", + " print(n)\n", + "\n", + "gds = '%s/lif_demo.gds' % check.SALIDA\n", + "celda.write_gds(gds)\n" + ] + }, + { + "cell_type": "markdown", + "id": "b52244b8", + "metadata": {}, + "source": [ + "### El condensador no es área por densidad\n", + "\n", + "Esta es la conversión menos obvia entre las dos capas. El solver da Cm en fF y el layout\n", + "necesita un lado en µm, pero el modelo del PDK no es una densidad plana:\n", + "\n", + " C = c_cox · area + c_capsw · perimetro\n", + "\n", + "A nuestro tamaño el término de perímetro **no** es despreciable: en un MIM de 5 µm de\n", + "lado aporta el 21% del total con la receta de 1.0 fF/µm². Multiplicar área por la\n", + "densidad que dice el nombre del modelo se queda corto justo donde trabajamos.\n", + "\n", + "Además `MIM.8a` no deja un FuseTop de menos de 25 µm², o sea 5 µm de lado. Por eso el\n", + "número de MIM en paralelo baja solo cuando la membrana es pequeña.\n", + "\n", + "El ejemplo de abajo lo enseña: con 95 fF (lo que pide 1200 kHz), la receta densa se\n", + "queda sin margen para repartir y se ve forzada a un solo MIM, mientras la ligera aun\n", + "cabe en tres. Es granularidad de la regla, no un fallo.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "e841ae9b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:09.575319Z", + "iopub.status.busy": "2026-08-27T19:04:09.574971Z", + "iopub.status.idle": "2026-08-27T19:04:09.632376Z", + "shell.execute_reply": "2026-08-27T19:04:09.628994Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "receta c_cox c_capsw MIM minimo (5x5)\n", + "1f0 0.987 fF/um2 0.330 fF/um 31.3 fF\n", + "2f0 1.990 fF/um2 0.238 fF/um 54.5 fF\n", + "\n", + " Cm=95 fF 1f0 con 1 MIM -> 9.16 um de lado ok\n", + " Cm=95 fF 1f0 con 2 MIM -> 6.30 um de lado ok\n", + " Cm=95 fF 1f0 con 3 MIM -> 5.03 um de lado ok\n", + " Cm=95 fF 2f0 con 1 MIM -> 6.67 um de lado ok\n", + " Cm=95 fF 2f0 con 2 MIM -> 4.65 um de lado ILEGAL por MIM.8a\n", + " Cm=95 fF 2f0 con 3 MIM -> 3.76 um de lado ILEGAL por MIM.8a\n" + ] + } + ], + "source": [ + "print('%-6s %16s %14s %s' % ('receta', 'c_cox', 'c_capsw', 'MIM minimo (5x5)'))\n", + "for r, (cox, sw) in mim.DENSIDADES.items():\n", + " print('%-6s %9.3f fF/um2 %8.3f fF/um %.1f fF'\n", + " % (r, cox, sw, mim.capacidad(5.0, r, 1)))\n", + "print()\n", + "# 95 fF es la membrana que sale a 1200 kHz, donde el limite muerde\n", + "for r in mim.DENSIDADES:\n", + " for n in (1, 2, 3):\n", + " lado = mim.lado_para(95.0, mim=r, n=n)\n", + " print(' Cm=95 fF %s con %d MIM -> %5.2f um de lado %s'\n", + " % (r, n, lado, 'ok' if lado >= 5.0 else 'ILEGAL por MIM.8a'))\n" + ] + }, + { + "cell_type": "markdown", + "id": "e9b0bf02", + "metadata": {}, + "source": [ + "## Verificación 1 — DRC\n", + "\n", + "Con dos trampas del deck que conviene conocer, porque las dos devuelven verde\n", + "sin comprobar lo que crees.\n", + "\n", + "El deck que trae glayout arranca `MIM_OPTION` a `\"Nan\"`, y todo el bloque de MIM es un\n", + "`if A ... elsif B`. Si no le pasas la opción **no comprueba ni una sola regla de MIM**.\n", + "Y arranca `METAL_LEVEL` a `\"6LM\"`, mientras el deck del propio PDK asume `5LM`: con seis\n", + "metales el deck cree que la cima es metaltop, así que `topmin1_via` pasa a ser via4 —\n", + "justo la via del MIM en opción B — y `MIMTM.10` acusa al condensador de llevar vias\n", + "prohibidas que son las suyas.\n", + "\n", + "`check.drc()` deriva las dos del PDK en vez de fijarlas a mano: `_mim_option()` mira\n", + "dónde pone el PDK las placas y `_metal_level()` cuenta los metales de la pila. Hoy sale\n", + "**opción B** — placa inferior en met4, FuseTop encima, placa superior en met5 y contacto\n", + "por via4 — y `5LM`. Si mañana el PDK se configura en opción A, el deck se entera solo." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "aaa40d23", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:09.638335Z", + "iopub.status.busy": "2026-08-27T19:04:09.638000Z", + "iopub.status.idle": "2026-08-27T19:04:49.675773Z", + "shell.execute_reply": "2026-08-27T19:04:49.670588Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "violaciones: 0 \n" + ] + } + ], + "source": [ + "n, categorias = check.drc(gds, 'lif_demo')\n", + "print('violaciones:', n, categorias if categorias else '')\n" + ] + }, + { + "cell_type": "markdown", + "id": "3753d06e", + "metadata": {}, + "source": [ + "## Verificación 2 — conectividad\n", + "\n", + "El DRC no ve cortos ni nodos flotantes, así que hace falta mirar la conectividad aparte.\n", + "\n", + "`netcheck.py` recorre el metal directamente: funde cada capa y deja que cada via suelde\n", + "lo que toca. Modela aparte que la via que aterriza sobre FuseTop contacta la placa\n", + "**superior**, no la inferior que hay debajo — sin eso todos los condensadores salen como\n", + "un cortocircuito. Qué via sea depende de dónde ponga el PDK el MIM: via2 con las placas\n", + "en met2/met3, **via4 con las placas en met4/met5**, que es donde están ahora.\n", + "\n", + "Nació porque el extractor no servía: el techfile de magic para gf180 pone el MIM entre\n", + "metal4 y metal5 y el nuestro estaba en met2/met3, así que `ext2spice` no veía los\n", + "condensadores. Con la migración a opción B esa discrepancia concreta desaparece, pero\n", + "`netcheck` se queda: es rápido, no necesita magic, y responde la pregunta que interesa\n", + "aquí — qué está unido con qué. Los dispositivos y sus parámetros los cubre el LVS, más\n", + "abajo.\n", + "\n", + "Se sondean puntos concretos y se agrupan por red. Las seis que tiene que haber:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "1fbb46c5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:49.682335Z", + "iopub.status.busy": "2026-08-27T19:04:49.681691Z", + "iopub.status.idle": "2026-08-27T19:04:59.638431Z", + "shell.execute_reply": "2026-08-27T19:04:59.633715Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " OK membrana M5_drain cap0_arriba cap1_arriba cap2_arriba nfet0_gate pfet0_gate\n", + " OK spike_neg nfet0_drain nfet1_gate nfet2_gate pfet0_drain pfet1_gate pfet2_gate\n", + " OK realimenta M5_gate nfet1_drain pfet1_drain\n", + " OK salida nfet2_drain pfet2_drain\n", + " OK VDD pfet0_source pfet1_source pfet2_source riel_VDD\n", + " OK VSS M5_source cap0_abajo cap1_abajo cap2_abajo nfet0_source nfet1_source nfet2_source riel_VSS\n" + ] + } + ], + "source": [ + "import json\n", + "\n", + "puntos = check.sondas(h, bb)\n", + "ruta = '%s/lif_demo.json' % check.SALIDA\n", + "json.dump(puntos, open(ruta, 'w'))\n", + "\n", + "grupos = check.redes(gds, ruta)\n", + "quiero = check.esperado(len(h['caps']))\n", + "\n", + "for red, miembros in quiero.items():\n", + " if any(g == miembros for g in grupos):\n", + " print(' OK %-11s %s' % (red, ' '.join(sorted(miembros))))\n", + " else:\n", + " visto = next((g for g in grupos if g & miembros), set())\n", + " print(' FALLA %-11s esperaba %s' % (red, ' '.join(sorted(miembros))))\n", + " print(' %-11s salio %s' % ('', ' '.join(sorted(visto)) or 'partida'))\n" + ] + }, + { + "cell_type": "markdown", + "id": "1d7929ba", + "metadata": {}, + "source": [ + "## El dibujo\n", + "\n", + "Recordatorio de por qué esto está aquí y no es decoración: varias veces la celda tenía\n", + "DRC 0 y estaba mal, y fue mirando el PNG como se vio.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "748e71eb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:04:59.647306Z", + "iopub.status.busy": "2026-08-27T19:04:59.646831Z", + "iopub.status.idle": "2026-08-27T19:05:09.351720Z", + "shell.execute_reply": "2026-08-27T19:05:09.346937Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "escrito /tmp/nbout/lif_demo.png\n" + ] + }, + { + "data": { + "image/png": "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H48xY8bg9u3bCAoKAgAIgoAPPvgA06dPr+DeVV288kdEREREROQEOPkjIiIiIiJyApz8ERERERE5odjYWDRu3Bj79u1D27Zt4e7ujhYtWuDPP/8EAPz000+QyWTIyMhQl+nevTsEQcC5c+fUaePGjUOHDh3UnwsKCjBr1izUrVsXcrkc9evXx2effWa/gZFenPwRERERETmplJQUTJgwAS+//DJ+/fVXeHl5YcCAAUhLS0NUVBSUSiX27t0LACgsLMT+/fuhUCiQkJCgriMxMRHR0dHqz8OGDcPSpUsxadIk/P777xg+fDhee+01fPPNN3YeHZXFBV+IiIiIiJxUeno6du7ciZYtWwIAwsPDERERgS1btmDkyJFo0KABEhMT0bt3bxw6dAhKpRKjR49GQkICnn/+edy6dQsXL15EVFQUgOKJ4Pr167Fp0yb07dsXQPHVwszMTMTFxeG5556DRMLrTxWFkSciIiIiclIBAQHqiR8ANGzYEFKpFNevXwcAREVFqa/yJSQkoEOHDujZsycSExPVaVKpFB07dgQAbN++Hd7e3ujZsyeKiorUP927d8etW7dw48YNu46PNPHKHxERERGRk/Lz89P4LJFIIJPJkJeXB6B48hcfH4+srCwkJiYiKioKXbp0QXJyMs6dO4fExES0atUK3t7eAIpvI83IyIBcLtfZ3rVr11C7dm3bDor04uSvghxOPmN22TZBTazYk6qLMbY9xpiIiKhqi4qKQlFRERISErBv3z5Mnz4d1atXR7NmzZCQkIDExET06dNHnb9atWrw8/PD9u3bddbXqFEje3WddLDots+bN2/ik08+QUxMDGrXrg25XI6goCA8/fTT+Pvvv7Xyx8XFQRAEvT9JSUmWdIeIiIiIiKwoNDQUderUwaJFi1BYWKhe1TMqKgo//fQTzp07p37eDwBiYmKQnp4OQRDQpk0brR8vL6+KGgrBwit/n376KRYsWIB69eohJiYG/v7+uHDhAjZs2IANGzZg1apVGDp0qFa50aNHIzw8XCvd19fXku4QEREREZGVldz62blzZ7i5uQEAoqOj8dlnn0EikaBz587qvN26dcPTTz+N3r17Y8qUKWjVqhXy8/Nx9uxZ7N27F7/++mtFDYNg4eSvXbt2SEhI0JjtA8CePXvQrVs3vPDCCxgwYABcXV01tsfGxmosB0tERERE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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import subprocess\n", + "from IPython.display import Image, display\n", + "\n", + "png = '%s/lif_demo.png' % check.SALIDA\n", + "lyrdb = '%s/lif_demo.lyrdb' % check.SALIDA\n", + "subprocess.run([sys.executable,\n", + " str(raiz / 'designs' / 'scripts' / 'lif_design' / 'preview.py'),\n", + " png, '%s:%s' % (gds, lyrdb)], check=True)\n", + "display(Image(filename=png))\n" + ] + }, + { + "cell_type": "markdown", + "id": "5168db95", + "metadata": {}, + "source": [ + "## Verificación 3 — LVS\n", + "\n", + "`netcheck` dice qué está unido con qué, pero no mira etiquetas ni dispositivos. El LVS\n", + "mira las tres cosas, y las tres se le escapan a las otras dos comprobaciones: una\n", + "etiqueta puesta sobre la red equivocada pasa el DRC y pasa `netcheck` sin quejarse.\n", + "\n", + "Aquí el LVS no es un *descubrimiento* sino una *comprobación*. En un flujo a mano se\n", + "dibuja el esquemático por un lado y el layout por otro, y la comparación revela si\n", + "divergieron. Estos dos salen de la misma `NeuronDesign`, así que un fallo solo puede\n", + "significar un bug del generador: la clase de fallo \"alguien tecleó distinto en dos\n", + "sitios\" no existe.\n", + "\n", + "Dos detalles que costaron encontrarlos, los dos por la misma razón — lo declarado no era\n", + "lo dibujado:\n", + "\n", + "- El deck extrae el condensador como **`cap_mim_2f0fF`**, sin sufijo de metales, mientras\n", + " el modelo de *simulación* es `cap_mim_2f0_m4m5_noshield`. Dos nombres para lo mismo, y\n", + " el netlist tiene que usar el primero. De ahí `mim.modelo()` y `mim.modelo_lvs()`.\n", + "- El GDS se escribe en rejilla de 0.005 µm y tanto la placa del MIM como el canal de un\n", + " FET van centrados, así que los bordes se van hacia fuera: una placa de 5.864 sale\n", + " dibujada de 5.87 y un M5 de 1.671 sale de 1.68. `en_rejilla()` sube cada dimensión a\n", + " esa rejilla antes de construir, y `handles[\"dims\"]` lleva lo dibujado — que es de donde\n", + " lee el netlist, no de `design.params`." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "b173adf8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:05:09.358325Z", + "iopub.status.busy": "2026-08-27T19:05:09.357949Z", + "iopub.status.idle": "2026-08-27T19:05:10.887697Z", + "shell.execute_reply": "2026-08-27T19:05:10.881879Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + ".subckt lif_lvs Vdd Vss Iin spike spike_neg\n", + "*.PININFO Vdd:B Vss:B Iin:I spike:O spike_neg:O\n", + "M1 spike_neg Iin Vdd Vdd pfet_03v3 L=0.28u W=0.22u nf=1 m=1\n", + "M2 spike_neg Iin Vss Vss nfet_03v3 L=0.28u W=0.22u nf=1 m=1\n", + "M3 spike/reset spike_neg Vdd Vdd pfet_03v3 L=0.28u W=0.22u nf=1 m=1\n", + "M4 spike/reset spike_neg Vss Vss nfet_03v3 L=0.28u W=0.22u nf=1 m=1\n", + "M7 spike spike_neg Vdd Vdd pfet_03v3 L=0.28u W=0.22u nf=1 m=1\n", + "M8 spike spike_neg Vss Vss nfet_03v3 L=0.28u W=0.22u nf=1 m=1\n", + "M5 Iin spike/reset Vss Vss nfet_03v3 L=35.42u W=1.68u nf=1 m=1\n", + "XC1 Iin Vss cap_mim_2f0fF W=5.87u L=5.87u M=3\n", + ".ends\n", + "\n", + "lado dibujado del MIM: 5.87 um\n", + "dimensiones dibujadas: {'W_M5': 1.68, 'L_M5': 35.42, 'W_M7M8': 0.22, 'W_inv': 0.22, 'L_inv': 0.28}\n" + ] + } + ], + "source": [ + "# El netlist de referencia sale del mismo diseno que el layout.\n", + "from lif_design import netlist\n", + "\n", + "d_lvs = design(NeuronSpec(freq_range=500, iex_range=100))\n", + "celda_lvs, h_lvs, _ = from_design(gf180, d_lvs, name='lif_lvs')\n", + "\n", + "print(netlist.de_diseño(d_lvs, h_lvs, name='lif_lvs'))\n", + "print('lado dibujado del MIM:', h_lvs['cap_lado'], 'um')\n", + "print('dimensiones dibujadas:', h_lvs['dims'])" + ] + }, + { + "cell_type": "markdown", + "id": "be427429", + "metadata": {}, + "source": [ + "## Robustez\n", + "\n", + "Su contrato es que devuelve siempre un diseño y cuenta lo que paso en las\n", + "notas. Eso importa porque quien lo llama es un generador, no una persona: si\n", + "lanzara, un barrido de mil neuronas se caeria en la primera absurda.\n", + "\n", + "Ocho entradas hostiles, incluidas dos que hacen saltar `ValueError` dentro de\n", + "las leyes (`vth` bajo la asintota de 1.2792 V no tiene ningun `Cm` que lo\n", + "alcance). Ninguna debe propagarse." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "20b53140", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:05:10.899363Z", + "iopub.status.busy": "2026-08-27T19:05:10.898732Z", + "iopub.status.idle": "2026-08-27T19:05:11.064565Z", + "shell.execute_reply": "2026-08-27T19:05:11.059085Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "entrada W_M5 error aviso primero\n", + "--------------------------------------------------------------------------------------------\n", + "nada 1.250 0 0 -\n", + "Cm absurdo 1.250 0 1 subida de 10 a 174 fF: bajo Cm_min la membra...\n", + "f imposible 1.250 1 1 50000 kHz esta sobre el maximo medido (4500 ...\n", + "Vth bajo la asintota 1.236 1 0 Vth=1.000 V esta en o bajo la asintota (1.27...\n", + "Vth sobre VDD 1.236 1 0 Vth=4.000 V exigiria Cm=41 fF, bajo el minim...\n", + "W y L fuera de rango 6.000 0 2 6.000 um esta fuera del rango medido (0.22-3...\n", + "Iex bajo el minimo 1.250 1 0 300 kHz a 0 nA no es alcanzable...\n", + "todo fijo y en contra 0.521 0 3 W=3.5 y L=50.0 fijas dan 163 kHz, no 3000. L...\n" + ] + } + ], + "source": [ + "hostiles = [\n", + " ('nada', NeuronSpec()),\n", + " ('Cm absurdo', NeuronSpec(Cm=10)),\n", + " ('f imposible', NeuronSpec(freq_range=50000, iex_range=100)),\n", + " ('Vth bajo la asintota', NeuronSpec(freq_range=500, iex_range=100, vth=1.0)),\n", + " ('Vth sobre VDD', NeuronSpec(freq_range=500, iex_range=100, vth=4.0)),\n", + " ('W y L fuera de rango', NeuronSpec(W_M5=6.0, L_M5=70.0)),\n", + " ('Iex bajo el minimo', NeuronSpec(freq_range=300, iex_range=0.5)),\n", + " ('todo fijo y en contra', NeuronSpec(freq_range=3000, iex_range=100,\n", + " W_M5=3.5, L_M5=50.0, Cm=1000)),\n", + "]\n", + "\n", + "print('%-22s %8s %6s %6s %s' % ('entrada', 'W_M5', 'error', 'aviso', 'primero'))\n", + "print('-' * 92)\n", + "for nombre, s in hostiles:\n", + " try:\n", + " d = design(s)\n", + " except Exception as exc:\n", + " print('%-22s LANZA %s: %s' % (nombre, type(exc).__name__, exc))\n", + " continue\n", + " sev = [n.severity for n in d.notes]\n", + " grave = next((n for n in d.notes if n.severity != 'info'), None)\n", + " print('%-22s %8.3f %6d %6d %s'\n", + " % (nombre, d.params['W_M5'], sev.count('error'), sev.count('warning'),\n", + " (grave.message[:44] + '...') if grave else '-'))" + ] + }, + { + "cell_type": "markdown", + "id": "2b5d5580", + "metadata": {}, + "source": [ + "### La capa de layout\n", + "\n", + "Lo anterior es un punto. Lo que importa es que aguante todo el rango que produce la capa\n", + "de caracterizacion, porque esas dimensiones no las elegimos nosotros.\n", + "\n", + "Dos tandas: siete juegos de dimensiones a mano y siete especificaciones recorriendo el\n", + "camino completo. Cada caso con DRC (con reglas MIM) y las seis redes.\n", + "\n", + "Las especificaciones no repiten geometria entre si: un objetivo de ganancia recorre\n", + "codigo distinto en el solver pero resuelve por `(f_hi, iex_hi)`, asi que daria el mismo\n", + "GDS que el caso de 800 kHz y no se le paga un DRC.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "55650b99", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-27T19:05:11.071505Z", + "iopub.status.busy": "2026-08-27T19:05:11.070806Z", + "iopub.status.idle": "2026-08-27T19:18:02.161803Z", + "shell.execute_reply": "2026-08-27T19:18:02.155315Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "caso caja um DRC topologia\n", + "--------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "base 53.88 x 24.65 0 ok\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "inv anchos 53.88 x 25.37 0 ok\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "M5 corto 38.11 x 24.65 0 ok\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "M5 ancho 53.88 x 26.90 0 ok\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cap grande 53.88 x 27.65 0 ok\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "todo grande 53.88 x 30.62 0 ok\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "todo minimo 38.11 x 23.67 0 ok\n", + "\n", + "spec caja um DRC topologia\n", + "--------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "200 kHz 50.17 x 30.58 0 ok (3 MIM)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "300 kHz 45.70 x 28.51 0 ok (3 MIM)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "800 kHz 39.30 x 25.20 0 ok (2 MIM)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2000 kHz 39.30 x 24.12 0 ok (1 MIM)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "umbral 2.0V 52.20 x 24.28 0 ok (2 MIM)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Iex 5 nA 29.28 x 23.94 0 ok (1 MIM)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "carga 800fF 39.30 x 26.24 0 ok (2 MIM)\n", + "\n", + "todos los casos pasan\n" + ] + } + ], + "source": [ + "_ = check.main()\n" + ] + }, + { + "cell_type": "markdown", + "id": "5fad8e58", + "metadata": {}, + "source": [ + "## Lo que falta\n", + "\n", + "- **Quién fija la opción de MIM para la oblea.** A (met2/FuseTop/met3) y B\n", + " (met4/FuseTop/met5) son excluyentes a nivel de proceso: las dos dibujan la misma capa\n", + " FuseTop, la (75,0), y una sola capa no puede declarar dos alturas en la pila. Esta\n", + " celda está en B porque es el defecto de `mimcap()` desde el PR #106 de glayout, no\n", + " porque nadie lo decidiera: nos llegó con un merge de upstream. Si la oblea es\n", + " compartida, esa elección no la puede tomar cada equipo por su cuenta, y hoy no está\n", + " escrita en ningún sitio del proyecto.\n", + "- **Confirmar la receta de MIM con el equipo.** El PDK ofrece 1.0, 1.5 y 2.0 fF/µm² pero\n", + " un proceso sólo puede usar una — es el grosor del dieléctrico, una lámina única en toda\n", + " la oblea. Aquí se usa `2f0`, la que menos área pide, y coincide con lo que dijo LuighiV\n", + " en el PR #109 de glayout: *\"the one we have in GF is the 2f0fF\"*. Ojo con no mezclar\n", + " los dos ejes: A y B **no** cambian la capacitancia por área, sólo el par de metales.\n", + "- **M6, el transistor de entrada, no está en el modelo.** Las leyes se midieron con una\n", + " fuente de corriente ideal en su lugar. En este diseño M6 vive en el encoder, que\n", + " entrega cuatro `Iex` desde sendos pfets de espejo, así que hay que pedirle al equipo su\n", + " impedancia de salida: por debajo de ~400 MΩ el error de frecuencia pasa del 5%.\n", + "- **La celda necesita cinco PRs de glayout que siguen sin mergear.** Con el pin que el\n", + " submódulo fija hoy no construye siquiera. Ver la tabla del PR de CapiMagics.\n", + "- Pozo compartido entre los tres nfets y entre los tres pfets — es donde queda el área que\n", + " se puede recuperar. Plegado de M5, y soportar `multipliers > 1`, que hoy sólo avisa." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62623a09-c814-422d-affe-46ae50e3ad73", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "GLdev (motor LIF)", + "language": "python", + "name": "gldev" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/designs/scripts/gdsview.py b/designs/scripts/gdsview.py new file mode 100644 index 0000000..3e609e4 --- /dev/null +++ b/designs/scripts/gdsview.py @@ -0,0 +1,281 @@ +"""Visor de GDS con zoom zonal y filtro de capas. + +La regla de trabajo es mirar el layout antes de tocarlo, y para que eso sirva la +imagen tiene que estar recortada a la zona y sin las capas que estorban. Un +volcado de todas las capas de la celda entera no ense~na nada. + +Ejemplos: + + # la zona del mimcap, solo las capas que importan + python gdsview.py opamp.gds --alrededor fusetop --solo met2,via2,met3,via3,met4 -o cap.png + + # una ventana concreta, escondiendo los metales altos + python gdsview.py lif.gds --zona 20,10,40,30 --ocultar met4,met5 -o zoom.png + + # el orden de dibujo se puede forzar; por defecto va de abajo a arriba + python gdsview.py lif.gds --solo met2,met3 --orden met3,met2 -o o.png + +La leyenda lleva el numero de poligonos de cada capa dentro de la ventana. Ese +contador es la mitad del diagnostico: una capa de vias con 0 donde deberia haber +conexion explica el fallo sin mirar nada mas. +""" +from __future__ import annotations + +import argparse +import sys +from collections import Counter + +import gdstk +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.patches import Polygon as MplPoly + +# gf180. nombre -> (capa, datatype, color, alpha) +CAPAS = { + # Un metal por familia de color, bien separadas: met1 y met2 compartian dos + # azules y en pantalla eran el mismo. Las vias van todas en negro -- son + # cuadraditos y lo unico que importa de ellas es donde estan. + "nwell": ((21, 0), "#94a3b8", 0.20), + "diff": ((22, 0), "#a16207", 0.35), + "poly2": ((30, 0), "#be123c", 0.35), + "nplus": ((32, 0), "#4ade80", 0.15), + "pplus": ((31, 0), "#f472b6", 0.15), + "contact": ((33, 0), "#111827", 0.85), + "met1": ((34, 0), "#a855f7", 0.45), # morado + "via1": ((35, 0), "#111827", 0.85), + "met2": ((36, 0), "#2563eb", 0.45), # azul + "via2": ((38, 0), "#111827", 0.85), + "met3": ((42, 0), "#16a34a", 0.40), # verde + "via3": ((40, 0), "#111827", 0.90), + "met4": ((46, 0), "#ea580c", 0.35), # naranja + "via4": ((41, 0), "#111827", 0.90), + "met5": ((81, 0), "#dc2626", 0.30), # rojo + "fusetop": ((75, 0), "#c026d3", 0.00), # solo contorno + "cap_mk": ((117, 5), "#f59e0b", 0.12), + "mim_l_mk":((117, 10),"#eab308", 0.12), +} + +# de abajo a arriba: lo de encima se dibuja despues +ORDEN = ["nwell", "diff", "nplus", "pplus", "poly2", "contact", "met1", "via1", + "met2", "via2", "fusetop", "met3", "via3", "met4", "via4", "met5", + "cap_mk", "mim_l_mk"] + +SOLO_CONTORNO = {"fusetop"} + + +def _lista(txt): + return [x.strip() for x in txt.split(",") if x.strip()] if txt else [] + + +# capas conductoras y que via une a que par de metales +PILA = [("met1", "via1", "met2"), ("met2", "via2", "met3"), + ("met3", "via3", "met4"), ("met4", "via4", "met5")] + + +def red_en(gds, celda, punto): + """Devuelve los poligonos de la red que toca `punto`, por capa. + + Recorre la pila de metales y vias uniendo lo que se solapa. Sirve para + contestar la unica pregunta que el DRC no contesta: *estas dos cosas estan + en la misma red o no*. Un corto se ve al instante -- la red se come medio + circuito -- y una conexion que falta tambien: la red se queda coja. + """ + import klayout.db as kdb + + ly = kdb.Layout() + ly.read(gds) + top = ly.cell(celda) if celda else ly.top_cell() + + def region(nombre): + capa, dt = CAPAS[nombre][0] + idx = ly.find_layer(capa, dt) + if idx is None: + return kdb.Region() + r = kdb.Region(top.begin_shapes_rec(idx)) + r.merge() + return r + + metales = {m: region(m) for m in ("met1", "met2", "met3", "met4", "met5")} + vias = {v: region(v) for _, v, _ in PILA} + + # Las vias que caen sobre el FuseTop son el dielectrico del MIM, no un + # contacto: unen el plato de abajo con el de arriba solo en apariencia. El + # deck del PDK las saca de la conectividad (`via2_n_cap = via2.not(fusetop)`) + # y aqui hay que hacer lo mismo, o el condensador sale en cortocircuito. + capmet = region("fusetop") + if not capmet.is_empty(): + for v in list(vias): + vias[v] = vias[v] - capmet + + # semilla: el poligono de metal que contiene el punto + px, py = punto + dbu = ly.dbu + caja = kdb.Region(kdb.Box(int((px - 0.02) / dbu), int((py - 0.02) / dbu), + int((px + 0.02) / dbu), int((py + 0.02) / dbu))) + neta = {m: kdb.Region() for m in metales} + for m, r in metales.items(): + sel = r.interacting(caja) + if not sel.is_empty(): + neta[m] = sel + break + else: + return None + + # crece hasta que deje de crecer: metal -> via -> metal, en los dos sentidos + for _ in range(40): + antes = sum(neta[m].count() for m in neta) + for abajo, via, arriba in PILA: + if vias[via].is_empty(): + continue + v_ab = vias[via].interacting(neta[abajo]) + if not v_ab.is_empty(): + neta[arriba] = (neta[arriba] + metales[arriba].interacting(v_ab)).merged() + v_ar = vias[via].interacting(neta[arriba]) + if not v_ar.is_empty(): + neta[abajo] = (neta[abajo] + metales[abajo].interacting(v_ar)).merged() + if sum(neta[m].count() for m in neta) == antes: + break + + salida = {} + for m, r in neta.items(): + polys = [[(p.x * dbu, p.y * dbu) for p in poly.each_point_hull()] + for poly in r.each()] + if polys: + salida[m] = polys + return salida + + +def _bbox(polys): + xs = [p[0] for poly in polys for p in poly.points] + ys = [p[1] for poly in polys for p in poly.points] + return min(xs), min(ys), max(xs), max(ys) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("gds") + ap.add_argument("-o", "--salida", default="vista.png") + ap.add_argument("--celda", help="nombre de celda; por defecto la primera") + ap.add_argument("--zona", help="x0,y0,x1,y1 en um") + ap.add_argument("--alrededor", help="recorta a la bbox de esta capa") + ap.add_argument("--margen", type=float, default=8.0) + ap.add_argument("--solo", help="dibuja unicamente estas capas") + ap.add_argument("--ocultar", help="quita estas capas") + ap.add_argument("--orden", help="fuerza el orden de dibujo") + ap.add_argument("--etiquetas", action="store_true", help="pinta los labels") + ap.add_argument("--red", help="x,y: resalta la red conductora que toca ese punto") + ap.add_argument("--titulo", default="") + ap.add_argument("--dpi", type=int, default=115) + a = ap.parse_args(argv) + + lib = gdstk.read_gds(a.gds) + celdas = {c.name: c for c in lib.cells} + if a.celda: + if a.celda not in celdas: + sys.exit(f"celda '{a.celda}' no esta. hay: {', '.join(sorted(celdas))}") + cell = celdas[a.celda] + else: + # la de nivel superior, no la primera de la lista: en un GDS jerarquico + # `cells[0]` suele ser una subcelda y la vista sale de unas micras + superiores = lib.top_level() + cell = superiores[0] if superiores else lib.cells[0] + polys = cell.get_polygons() + + visibles = [n for n in (a.orden and _lista(a.orden) or ORDEN) if n in CAPAS] + if a.solo: + pedidas = _lista(a.solo) + desconocidas = [n for n in pedidas if n not in CAPAS] + if desconocidas: + sys.exit(f"capa(s) desconocida(s): {', '.join(desconocidas)}") + visibles = [n for n in visibles if n in pedidas] + # respeta el orden pedido si se uso --orden + if a.orden: + visibles = [n for n in _lista(a.orden) if n in pedidas] + for n in _lista(a.ocultar): + if n in visibles: + visibles.remove(n) + + # ventana + if a.zona: + x0, y0, x1, y1 = (float(v) for v in a.zona.split(",")) + elif a.alrededor: + ld = CAPAS[a.alrededor][0] + sel = [p for p in polys if (p.layer, p.datatype) == ld] + if not sel: + sys.exit(f"no hay poligonos de '{a.alrededor}' en {cell.name}") + bx0, by0, bx1, by1 = _bbox(sel) + x0, y0, x1, y1 = bx0 - a.margen, by0 - a.margen, bx1 + a.margen, by1 + a.margen + else: + x0, y0, x1, y1 = _bbox(polys) + + ancho = max(x1 - x0, 1e-6) + alto = max(y1 - y0, 1e-6) + fig, ax = plt.subplots(figsize=(13, min(13 * alto / ancho + 1.0, 16))) + + cuenta = Counter() + for nombre in visibles: + ld, color, alpha = CAPAS[nombre][0], CAPAS[nombre][1], CAPAS[nombre][2] + for p in polys: + if (p.layer, p.datatype) != ld: + continue + pts = p.points + if max(q[0] for q in pts) < x0 or min(q[0] for q in pts) > x1: + continue + if max(q[1] for q in pts) < y0 or min(q[1] for q in pts) > y1: + continue + cuenta[nombre] += 1 + if nombre in SOLO_CONTORNO: + ax.add_patch(MplPoly(pts, closed=True, facecolor="none", + edgecolor=color, lw=1.8, zorder=9)) + else: + ax.add_patch(MplPoly(pts, closed=True, facecolor=color, + edgecolor=color, alpha=alpha, lw=0.3)) + ax.plot([], [], color=color, lw=6, + alpha=max(alpha, 0.5), label=f"{nombre} ({cuenta[nombre]})") + + if a.red: + px, py = (float(v) for v in a.red.split(",")) + neta = red_en(a.gds, a.celda, (px, py)) + if neta is None: + print(f" no hay metal en ({px}, {py})") + else: + total = 0 + for nombre, polys in neta.items(): + for pts in polys: + total += 1 + ax.add_patch(MplPoly(pts, closed=True, facecolor="#facc15", + edgecolor="#a16207", alpha=0.55, lw=0.6, + zorder=11)) + ax.plot([], [], color="#facc15", lw=6, + label="red en (%.1f,%.1f): %d" % (px, py, total)) + print(" red en (%.1f,%.1f): " % (px, py) + + " ".join(f"{k}={len(v)}" for k, v in neta.items())) + ax.plot([px], [py], marker="x", color="#111827", ms=12, mew=2.5, zorder=13) + + if a.etiquetas: + for lab in cell.labels: + lx, ly = lab.origin + if x0 <= lx <= x1 and y0 <= ly <= y1: + ax.text(lx, ly, lab.text, fontsize=7, color="#111827", zorder=12, + ha="center", va="center", + bbox=dict(fc="white", ec="none", alpha=0.65, pad=0.8)) + + ax.set_xlim(x0, x1) + ax.set_ylim(y0, y1) + ax.set_aspect("equal") + ax.legend(loc="upper left", fontsize=8, ncol=2) + ax.set_title(a.titulo or f"{cell.name} [{x0:.1f},{y0:.1f}] - [{x1:.1f},{y1:.1f}] um") + plt.tight_layout() + plt.savefig(a.salida, dpi=a.dpi) + + vacias = [n for n in visibles if cuenta[n] == 0] + print(f"{a.salida} ventana {x1-x0:.1f} x {y1-y0:.1f} um") + print(" " + " ".join(f"{n}={cuenta[n]}" for n in visibles)) + if vacias: + print(" sin poligonos en la ventana: " + ", ".join(vacias)) + + +if __name__ == "__main__": + main() diff --git a/designs/scripts/lif_design/OVERLAP_NOTES.md b/designs/scripts/lif_design/OVERLAP_NOTES.md new file mode 100644 index 0000000..073f27f --- /dev/null +++ b/designs/scripts/lif_design/OVERLAP_NOTES.md @@ -0,0 +1,110 @@ +# How far two glayout blocks can overlap + +Notes from measuring, not from the manual. Every number here came from +generating the cells and checking the result. + +Reference block: `nmos(pdk, width=1.08, length=35.42, with_substrate_tap=False, +with_dummy=False, with_tie=False)` on GF180MCU — 42.32 × 9.21 µm. + +## The bbox is mostly empty + +``` +bbox -21.160 … 21.160 +nplus -18.540 … 18.540 <- outermost layer +poly2 -17.710 … 17.710 +comp -18.310 … 18.310 + margin: 2.62 µm per side +``` + +A glayout FET carries ~2.6 µm of empty bbox on each side. Two blocks placed +edge to edge therefore waste ~5.2 µm of channel that is already there, which +is why overlapping the bboxes is not automatically wrong. + +The neuron in `layout/lif/neurona.ipynb` overlaps its blocks by 3.22 µm — well +inside the padding — but still reports 31 DRC violations: CO.2a x14 and V1.2a +x14 (contact and via spacing), NW.3 x2, DN.3. Overlapping the padding is fine; +what it costs is the clearance around contacts and vias near the edges. + +## What actually limits the overlap + +Two blocks were placed at overlaps from 0 to 8 µm and the DRC run on each. + +**Use the deck glayout ships**, `src/glayout/pdk/gf180_mapped/gf180mcu.drc`, +not the one under `$PDK_ROOT/libs.tech/klayout/tech/drc/`. The latter returns +an empty report for these layouts — zero items even for a GDS with six known +violations — so it reads as "clean" when it has evaluated nothing. + +``` +overlap DRC items rules comp regions + 0.00 µm 5 DN.3, DF.8_3.3V x4 2 + 5.00 µm 4 DN.3, DF.8 x2, NP.2 2 + 5.50 µm 4 DN.3, DF.8 x2, DF.3a_3.3V 2 + 5.80 µm 19 + M1.2a x11, M2.2a x5 1 + 8.00 µm 6 + CO.10 1 +``` + +Two things to read here: + +**The bare block already violates five rules at zero overlap.** `DN.3` and +`DF.8_3.3V` come from placing an `nmos` with no tap ring and no substrate +contact — they are not caused by the overlap and do not move with it. + +**The overlap limit is 5.5 µm.** That is where `DF.3a_3.3V` appears, the +minimum comp-to-comp spacing (0.28 µm): the two diffusions have closed to +0.20 µm. Just past it, at 5.8 µm, metal1 and metal2 start colliding (11 + 5 +violations) and the two comp regions merge into one — the devices stop being +two transistors. + +This matches the geometric prediction: 2 × 2.62 µm of bbox padding = 5.24 µm +before the active layers meet. + +## Practical rule + +``` +max_overlap = 2 × (bbox padding of the layer that reaches furthest out) +``` + +Measure the padding per block rather than assuming it: it changes with the +parameters below. + +## Block width is not W + +`multiplier()` builds the finger pitch from the PDK rules: + +```python +poly_spacing = 2·rule("poly","mcon").min_separation + rule("mcon").width +poly_spacing = max(sd_via_x_dim, poly_spacing) # sd_via_x_dim scales with rmult +poly_spacing += met1_min_separation if length < met1_min_separation +``` + +so the same W lands at very different widths: + +| variant | X | Y | +|---|---|---| +| nominal | 42.32 | 9.21 | +| fingers=2 | 78.30 | 9.21 | +| fingers=4 | 150.26 | 9.21 | +| with_dummy=True | 117.20 | 9.21 | +| with_tie=True | 44.28 | 11.18 | +| sd_rmult=2 | 42.32 | 10.21 | + +Fingers **expand** in X, they do not compact. `with_dummy` nearly triples the +width. This is the flip side of the electrical result: fingers move frequency +by ≤0.40% (inside simulation noise) but move area by +85% to +255%, so the +choice belongs entirely to the layout — and the floorplanner must take the +measured bbox as input rather than deriving it from W. + +## Parameters worth knowing + +From `multiplier()`'s signature, these change the outline: + +``` +sd_route_extension extends the source/drain connections outward +gate_route_extension extends the gate connection outward +sd_rmult thickens s/d metal (grew Y from 9.21 to 10.21) +dummy adds dummy active regions on both sides +``` + +`sd_route_extension` and `gate_route_extension` are the interesting pair for +a floorplanner: instead of leaving a gap and routing across it afterwards, the +FET itself can be asked to reach into the channel. diff --git a/designs/scripts/lif_design/README.md b/designs/scripts/lif_design/README.md new file mode 100644 index 0000000..d405bf2 --- /dev/null +++ b/designs/scripts/lif_design/README.md @@ -0,0 +1,90 @@ +# LIF design engine — GF180MCU + +Turns an electrical intent into the dimensions of the LIF neuron, and says +what it had to change and why. A tool *for* an AI to use, not an AI: the +input is deterministic and the output is structured data, including the +reason something cannot be done. + +```python +from lif_design import NeuronSpec, design + +d = design(NeuronSpec(freq_range=(200, 1500), iex_range=(20, 200))) +print(d.report()) +``` + +## The laws + +Fitted to ngspice sweeps on GF180MCU, `.tran 1n`. Frequencies are **kHz**, +dimensions **µm**, capacitance **fF**, current **nA**. + +| | law | RMS | +|---|---|---| +| frequency | `f = 24837 · W⁻¹·⁰⁷⁶ · L⁻⁰·⁹⁴⁰ · (Iex/100nA)` | 2.03% | +| gain | `k = 280.22 · W⁻¹·⁰⁴⁴⁷ · L⁻⁰·⁹⁹²³` | 2.18% | +| threshold | `Vth = 1.2792 + (−16.83W + 0.4884L + 1.766WL)/Cm` | 1.32% | +| swing | `swing = 4.114 · W⁰·⁹⁵¹ · L¹·⁰⁶⁵ · Cm⁻¹·⁰⁰⁶` | 1.68% | +| oscillation floor | `Cm_min = 8.94 · W¹·⁰³⁸ · L⁰·⁷⁰⁰` | — | +| input capacitance | `C_in = 0.945 + 0.865 · W` | 0.67% | + +Externally validated on 18 points outside the fitting grid: frequency error +−0.00% mean, 1.23% RMS. + +W and L are M5, the integrator transistor. The inverters (M1–M4) are not +characterised — they are sized as ordinary digital gates. + +`C_in` is what the cell presents to whatever drives current into it, on top of +`Cm`. It is a predicted output, never an objective: it depends only on `W_M5`, +and 1.1–4.0 fF across the envelope is too narrow to constrain anything. A +`c_in_max` in the spec is checked, not solved. **It must not be added to `f`** — +the frequency law was fitted on simulations that already include it. + +## Validity range + +``` +W 0.22 … 3.5 µm above 3.5 the membrane leaves the rail +L 20 … 50 µm L=60 does not converge +L ≥ 25 µm below this, frequency error rises from ~1% to 5–7% +f ≤ 4500 kHz the reset does not complete below ~215 ns +Iex ≥ 5 nA verified with no degradation; there is no real floor +``` + +Outside these the laws are extrapolation, and `design()` says so in its notes. + +## What does not affect the electrical result + +`fingers` and `multipliers` on M5 are a layout decision. Measured on +W=1.08 L=35.42 Cm=141.1 fF: frequency moves ≤0.40% between 1, 2 and 4 +fingers, which is inside the cycle-to-cycle spread of the simulation itself +(0.13–0.32%) and well under the laws' own 2.03% RMS. + +So the layout picks them for area and routing, and does not have to report +back. + +## Resolution layers + +`design()` resolves in four passes, and never raises — see `NeuronDesign.notes`: + +1. **geometry** — (W, L) from the frequency target +2. **Cm** — from the threshold, floored at `Cm_min` +3. **output buffer** — sized from the load, independent of the rest +4. **validation** — predicts what the design will do, flags what it cannot meet + +Design intent wins over fixed dimensions: if a pinned W or L contradicts the +target, the engine adjusts it and emits a WARNING; if the contradiction cannot +be resolved at all, an ERROR with the causal chain. + +## Files + +``` +laws.py the fitted laws and their inverse solvers +spec.py NeuronSpec (what you ask) / NeuronDesign (what you get) +solver.py the four resolution layers +example.py six runnable cases +``` + +Verification lives with the testbench, in +`designs/libs/tb_analog/tb_lif/` — it simulates a design and compares the +measurement against the prediction. + +Standard library only: `math`, `dataclasses`, `enum`. No numpy — a design is +about fifteen `pow()` calls. diff --git a/designs/scripts/lif_design/__init__.py b/designs/scripts/lif_design/__init__.py new file mode 100644 index 0000000..342524e --- /dev/null +++ b/designs/scripts/lif_design/__init__.py @@ -0,0 +1,36 @@ +"""Sistema de diseño por capas para la neurona LIF (GF180MCU). + +Herramienta PARA que la use una IA, no una IA: la entrada es determinista y la +salida son datos estructurados, incluido el detalle de por que algo no se puede. + +Uso: + from design import NeuronSpec, design + + # "haz una neurona y ya" -> punto nominal medido + d = design(NeuronSpec()) + + # con objetivos + d = design(NeuronSpec(iex_range=(20, 200), freq_range=(200, 1500))) + + # con dimensiones ya calculadas por el diseñador + d = design(NeuronSpec(W_M5=1.0, freq_range=(500, 500))) + + print(d.report()) + if not d.ok: + for e in d.errors: + print(e.chain) + +Solo stdlib: math, dataclasses, enum. Sin numpy -- son ~15 pow() por diseño. +""" +from .laws import ( + c_load_max, Cm_min, freq, freq_at_iex_ref, gain, iex_window, + min_source_impedance, swing, vth, vth_max_at, +) +from .solver import NOMINAL, design +from .spec import NeuronDesign, NeuronSpec, Note, Severity + +__all__ = [ + "NeuronSpec", "NeuronDesign", "Note", "Severity", "design", "NOMINAL", + "freq", "freq_at_iex_ref", "gain", "vth", "swing", "Cm_min", + "iex_window", "c_load_max", "min_source_impedance", "vth_max_at", +] diff --git a/designs/scripts/lif_design/build.py b/designs/scripts/lif_design/build.py new file mode 100644 index 0000000..8555544 --- /dev/null +++ b/designs/scripts/lif_design/build.py @@ -0,0 +1,1143 @@ +"""Generation: turn a placement plan into glayout geometry. + +Kept apart from `place.py` on purpose. Placement is arithmetic over design +rules and can be exercised without generating anything; this module is where +the glayout dependency, the port names and the ordering of routing calls all +live. Mixing them means a spacing calculation cannot be checked without +building a layout. + +The flow inside generation runs in two passes, which reads like an inversion +but is not: primitives have to exist before their size and wells can be read, +so it goes + + generate primitives -> plan (place.py) -> assemble at the planned x + +Power rails are drawn here rather than left to the caller. A cell that exposes +VDD and VSS as ports and never connects them looks finished and is not -- the +LIF neuron reached DRC-clean in that state, which is exactly how the omission +survived. +""" +from __future__ import annotations + +from decimal import ROUND_UP, Decimal + +from dataclasses import dataclass +from typing import Optional +from warnings import warn + +from . import mim as mim_pdk +from .place import MIM_BOTTOM_TO_MET2 as MIM_A_MET2 +from .place import Cell, Rails, Stack, pair, plan_row +from .spec import Note, Severity + +# Ports a glayout FET offers that this module relies on. +GATE = "multiplier_0_gate_{side}" +DRAIN = "multiplier_0_drain_{side}" +SOURCE = "multiplier_0_source_{side}" +# The tie has ports on comp and on the top metal. Routing from the comp one +# drops a full via stack in the middle of the tie's own contact row -- 0.18 um +# between contacts where the rule wants 0.25. The narrow top-metal port on the +# side avoids both that and the wide N/S port, which is broad enough to merge +# with whatever it passes. +TIE = "tie_{end}_top_met_E" + +# met3 es la capa mas alta que los transistores dejan libre. Con el MIM en +# met4/met5 (opcion B, la que sigue el equipo) esas dos ya no lo estan sobre +# la huella del banco, asi que met3 no se elige por dejar met4 abierto: se +# elige porque es la que hay. Fuera del banco met4 y met5 siguen disponibles +# para el ruteo entre neuronas. +RIEL_POR_DEFECTO = "met3" + + +@dataclass +class Inverter: + """One placed inverter and the ports the row needs from it.""" + ref_p: object + ref_n: object + stack: Stack + + @property + def vdd_port(self): + return self.ref_p.ports[SOURCE.format(side="N")] + + @property + def vss_port(self): + return self.ref_n.ports[SOURCE.format(side="S")] + + @property + def in_port(self): + return self.ref_n.ports[GATE.format(side="E")] + + @property + def out_port(self): + return self.ref_n.ports[DRAIN.format(side="W")] + + @property + def vdd_tie(self): + return self.ref_p.ports[TIE.format(end="N")] + + @property + def vss_tie(self): + return self.ref_n.ports[TIE.format(end="S")] + + +def _into_metal(port, distance: float) -> float: + """How far to step in y so a via lands on the port's metal, not past it. + + A port marks the edge of its shape and faces *away* from it: a south + facing port has its metal to the north. Centring a via on the port leaves + half of it hanging over whatever is on the other side -- on a glayout FET + that is the tie ring, a few tens of nanometres away. Ports on a vertical + edge (east, west) need no y step at all. + """ + angle = (port.orientation or 0) % 360 + if 45 < angle < 135: # faces north -> metal is south + return -distance + if 225 < angle < 315: # faces south -> metal is north + return +distance + return 0.0 + + +def _merge_columns(points, width: float, sep: float) -> list[list]: + """Group drop positions that are too close to stand as separate shapes. + + Two rectangles of `width` centred less than `width + sep` apart leave a + gap the spacing rule rejects. Since every drop on one rail carries the + same net, the fix is to draw them as one rectangle rather than to shove + them apart -- shoving would walk the via off the metal it has to land on. + + `points` must be sorted by x. + """ + groups: list[list] = [] + for point in points: + if groups and point[0] - groups[-1][-1][0] < width + sep: + groups[-1].append(point) + else: + groups.append([point]) + return groups + + +def _center_on(ref, x: float, y: float): + """Move a reference so its centre lands on (x, y). + + `move(destination=...)` is a plain translation in gdsfactory -- origin + defaults to (0, 0), not to the reference centre -- so placing by centre + has to be written as an explicit delta. + """ + cx, cy = ref.center + ref.movex(float(x) - float(cx)).movey(float(y) - float(cy)) + return ref + + +def inverter_row(pdk, nmos_params: dict, pmos_params: dict, count: int = 3, + pair_gap: float = 0.95, rails: Optional[Rails] = None, + stages: Optional[list] = None, chain: bool = False, + channel_tracks: int = 2): + """A row of inverters with VDD and VSS rails, wired to both. + + `chain` wires stage i to stage i+1, turning the row into a chain + instead of three independent inverters. + + `stages` gives per-inverter sizing as [(nmos_params, pmos_params), ...] and + overrides `count`. The row is not uniform in practice: in a LIF cell the + threshold inverter sets the trip point the characterisation was fitted + around and must not move, while the output inverter is sized for whatever + it drives. Sizing them all alike is the special case, not the rule. + + Returns (Component, [Inverter]). Spacing comes from place.plan_row, so the + only distance decided here is `pair_gap` -- the room the gate and drain + links need to turn between the two devices, which no well rule constrains + because gf180 lets nwell and pwell abut. + """ + from glayout.backend import Component, rectangle + from glayout.primitives.fet import nmos, pmos + from glayout.routing.c_route import c_route + from glayout.routing.L_route import L_route + from glayout.routing.straight_route import straight_route + + if any(p.get("multipliers", 1) > 1 + for stage in (stages or [(nmos_params, pmos_params)]) for p in stage): + warn("multipliers > 1 doubles the device height, and the rail drops " + "then run the full way down it alongside the gate and drain " + "links; they end up 0.10 um apart where met3 wants 0.30. The " + "cell is still correctly connected -- only the spacing fails -- " + "but placing the drops clear of the routing needs a router that " + "knows where the routes are. Use fingers instead: they widen the " + "device without making it taller, and come out DRC clean.") + + if stages is None: + stages = [(nmos_params, pmos_params)] * count + + devices, stacks, blocks = [], [], [] + for i, (nparams, pparams) in enumerate(stages): + pfet = pmos(pdk, **pparams) + nfet = nmos(pdk, **nparams) + stack = pair(Cell.from_component("pfet", pfet, pdk), + Cell.from_component("nfet", nfet, pdk), + pdk, minimum=pair_gap, name=f"inv{i}") + as_cell = stack.as_cell() + devices.append((pfet, nfet)) + stacks.append(stack) + blocks.append(Cell(f"inv{i}", stack.width, stack.height, + as_cell.wells, as_cell.layers)) + + # One layer above whatever the blocks reach, so the straps fly over. The + # tallest stage sets the rail height for the whole row: shorter ones sit + # inside it rather than each getting its own pair of rails. + tallest = max(blocks, key=lambda b: b.height) + # One track of channel per chained link; an unchained row needs none. + rails = rails or Rails.above(pdk, blocks, + tracks=channel_tracks if chain else 0) + plan = plan_row(blocks, [], pdk, rails=rails) + + top = Component(name="inverter_row") + invs: list[Inverter] = [] + + for i, (stack, (pfet, nfet)) in enumerate(zip(stacks, devices)): + cx = plan.x[i] + stack.width / 2 + offsets = stack.offsets() + ref_p = top << pfet + ref_n = top << nfet + # Stages of different height share one pair of rails, so they are + # placed against the rails rather than centred on the row: the source + # of a small inverter has to reach the same strap as a big one. + _center_on(ref_p, cx + offsets["pfet"][0], offsets["pfet"][1]) + _center_on(ref_n, cx + offsets["nfet"][0], offsets["nfet"][1]) + ref_p.name, ref_n.name = f"pfet_{i}", f"nfet_{i}" + + # gate to gate and drain to drain: the two links that make it an + # inverter, and the reason the pair needs any vertical gap at all. + top << c_route(pdk, ref_p.ports[GATE.format(side="W")], + ref_n.ports[GATE.format(side="W")]) + top << c_route(pdk, ref_p.ports[DRAIN.format(side="E")], + ref_n.ports[DRAIN.format(side="E")]) + # The body ties are not wired to their source here. Doing so needs a + # met2 run from the tie, at the device edge, to the source port, which + # sits inside the device -- and that run crosses the gate on the way, + # shorting input to source. Both belong on the same rail anyway, so + # each is taken up to it separately and meets there. + invs.append(Inverter(ref_p, ref_n, stack)) + + if chain: + # Stage i drives stage i+1. The output leaves on the west (the drain's + # east port is taken by the pfet-to-nfet link) and the input arrives on + # the east (the west gate port is taken by the gate-to-gate link), so + # the signal runs east to west and the chain is laid out right to left: + # driving left to right would send every hop back across two blocks. + # Pairing runs right to left: the driver is the eastern stage, whose + # west-facing drain then points straight at the load's east-facing + # gate. Driving the other way leaves the two ports back to back, which + # is why straight_route silently fails to join them -- the same shape + # Abrahan's neuron has, where the OUT-to-IN links never connected. + # The link leaves both stages southward and runs across in the channel + # reserved under the row. `extension` is what puts it there: left at + # its default the crossing segment lands 0.5 um below the ports, which + # is still inside the device, and the route merges with the tie ring + # and the rail instead of connecting anything. Reserving the channel + # is necessary but not sufficient -- the router has to be aimed at it. + # The drain's south port sits at the *top* of the device and merely + # faces south, so a route leaving through it runs down across the + # device's own source and tie ring on the way out -- which is how the + # link kept shorting to VSS no matter how wide the channel got. It has + # to leave sideways instead. drain_W is horizontal and gate_S is + # vertical, so the corner between them is an L. + for driver, load in zip(invs[1:], invs): + top << L_route(pdk, driver.out_port, + load.ref_n.ports[GATE.format(side="S")]) + + _add_rails(pdk, top, invs, tallest.height, plan, rails, rectangle) + # Signal enters at the eastern end and leaves at the western one. + top.add_port(name="IN", port=invs[-1].in_port if chain else invs[0].in_port) + top.add_port(name="OUT", port=invs[0].out_port if chain else invs[-1].out_port) + return top, invs + + +def _add_rails(pdk, top, invs, row_height: float, plan, rails: Rails, rectangle): + """Draw VDD above and VSS below, then tie every inverter to both. + + The rails sit one layer above the blocks. That is not a preference: a + glayout FET occupies met2 out to its own tie ring, and its source port + sits *inside* that ring rather than on the boundary, so a met2 drop from + the source to a rail crosses the tie on the way out and shorts the two + together -- which is what a first attempt here did, taking every net in + the row with it. On met3 the strap flies over the ring untouched and only + comes down, through a via stack, exactly on the source. + + The straps span the full row width so abutting rows can share them. + """ + from glayout.primitives.via_gen import via_stack + + rail_layer = pdk.get_glayer(rails.glayer) + # The rails clear the tallest stage, so every stage reaches the same + # strap regardless of its own height. + half = row_height / 2 + # band, not clearance: the rail sits beyond the routing channel too. + y_vdd = pdk.snap_to_2xgrid(half + rails.band - rails.width / 2) + y_vss = -y_vdd + + for y in (y_vdd, y_vss): + strap = top << rectangle( + size=pdk.snap_to_2xgrid([plan.width, rails.width]), + layer=rail_layer, centered=True) + _center_on(strap, pdk.snap_to_2xgrid(plan.width / 2), y) + + from glayout.util.comp_utils import evaluate_bbox + + climb = via_stack(pdk, "met2", rails.glayer) + via_h = evaluate_bbox(climb)[1] + + # First place every via, then draw the straps: which drops can share one + # rectangle is a property of the whole row, not of one inverter. + via_w = evaluate_bbox(climb)[0] + clear = via_w + float(pdk.get_grule(rails.glayer)["min_separation"]) + + landings: dict[float, list[tuple[float, float]]] = {y_vdd: [], y_vss: []} + for inv in invs: + for source, tie, y_rail in ((inv.vdd_port, inv.vdd_tie, y_vdd), + (inv.vss_port, inv.vss_tie, y_vss)): + # The tie is the eastern port, so it lands where it is. The source + # then has to sit east of the device midline -- the gate runs up + # the middle and a via on it overlaps, a short no spacing rule + # reports because the shapes touch rather than crowd -- but not so + # far east that it crowds the tie's own via. A quarter of the port + # width is the natural offset and works until the port is wide, + # which is what a second multiplier does: the offset grows with + # the port and walks the via to 0.10 um of the tie's, where met3 + # wants 0.30. Via stacks cannot be merged away like the straps + # above them, so the offset is capped instead. + x_tie = pdk.snap_to_2xgrid(float(tie.center[0]) + tie.width / 4) + wanted = float(source.center[0]) + source.width / 4 + x_src = pdk.snap_to_2xgrid(min(wanted, x_tie - clear)) + if x_src <= float(source.center[0]): + warn(f"no room east of the gate for the {y_rail:+.2f} drop on " + f"{source.name}; leaving it at {wanted:.3f} and expecting " + f"a spacing violation against the body tie") + x_src = pdk.snap_to_2xgrid(wanted) + for port, x in ((source, x_src), (tie, x_tie)): + # A port marks the *edge* of its metal, not the middle: + # centring a via on it leaves half the stack hanging off the + # source and into whatever sits beyond -- on a glayout FET, + # the tie ring a few tens of nm away. Step in by half a stack. + y_via = pdk.snap_to_2xgrid( + float(port.center[1]) + _into_metal(port, via_h / 2)) + _center_on(top << climb, x, y_via) + landings[y_rail].append((x, y_via)) + + # Drops are minimum width, not the port's: at port width the source and + # tie of one device sit 0.10 um apart where met3 wants 0.30, and they + # carry the same net, so widening them buys nothing a wider rail would + # not. Two drops that still end up closer than the rule share a single + # rectangle instead -- same net, so merging is free, and it is the only + # way out when the crowding comes from two different devices. + sep = float(pdk.get_grule(rails.glayer)["min_separation"]) + for y_rail, points in landings.items(): + for group in _merge_columns(sorted(points), rails.width, sep): + xs = [p[0] for p in group] + left, right = min(xs) - rails.width / 2, max(xs) + rails.width / 2 + deepest = max((p[1] for p in group), key=lambda y: abs(y_rail - y)) + drop = top << rectangle( + size=pdk.snap_to_2xgrid([right - left, abs(y_rail - deepest)]), + layer=rail_layer, centered=True) + _center_on(drop, pdk.snap_to_2xgrid((left + right) / 2), + pdk.snap_to_2xgrid((y_rail + deepest) / 2)) + + top.add_port(name="VDD", port=invs[0].vdd_port) + top.add_port(name="VSS", port=invs[0].vss_port) + + +# -------------------------------------------------------------------------- +# the LIF cell +# -------------------------------------------------------------------------- + +# Ports the routing relies on, and why each side rather than another. +_GATE_MID = "multiplier_0_gate_{side}" +_DRAIN_MID = "multiplier_0_drain_{side}" + + +def lif_cell(pdk, inverter: dict, m5: dict, cap_size: float = 5.0, + supply_width: float = 1.0, output_inverter: dict | None = None, + n_caps: int = 3, rail_layer: Optional[str] = RIEL_POR_DEFECTO, + name: str = "lif"): + """A LIF neuron: three inverters, the reset device and the membrane cap. + + Laid out in bands rather than as a row of inverters. Grouping by device + type is what makes it compact: the pfets share one band, the nfets another, + and the strip beside the long reset device -- 45% dead area in a single + row -- holds the capacitors. + + pfets <- top + M5 (reset) <- spans the full width; its length IS the cell width + nfets + caps <- bottom + + That puts M5 between the two halves of every inverter, so the gate and + drain links cross it. They can, because M5 stops at met2 and met3 is free + over the whole band. The rails then go to met4, since the capacitors reach + met3. Nobody designed that assignment -- it falls out of asking, at each + step, which layer is free above what has to be crossed. + + Returns (Component, dict of references). + """ + from glayout.backend import Component, rectangle + from glayout.primitives.fet import nmos, pmos + from glayout.primitives.mimcap import mimcap + from glayout.primitives.via_gen import via_stack + from glayout.routing.c_route import c_route + from glayout.routing.straight_route import straight_route + from glayout.util.comp_utils import evaluate_bbox + + from .place import Band, plan_bands + + # The third inverter is the output buffer -- M7/M8 in the netlist -- and + # the solver sizes it on its own, by the load it has to drive. The other + # two are the ones inside the loop and stay minimum. + salida = output_inverter or inverter + + pfet_c = pmos(pdk, **inverter) + nfet_c = nmos(pdk, with_dnwell=False, **inverter) + pfet_o_c = pmos(pdk, **salida) + nfet_o_c = nmos(pdk, with_dnwell=False, **salida) + m5_c = nmos(pdk, with_dnwell=False, **m5) + cap_c = mimcap(pdk, size=(cap_size, cap_size)) + + m5_cell = Cell.from_component("M5", m5_c, pdk) + cap = Cell.from_component("cap", cap_c, pdk) + + def clones(cell, n, prefix): + # insets included: without them the MIM.1 clearance is computed as if + # the cap's met2 plate reached its outline, which it does not, and the + # planner asks for more room than the rule wants. + return [Cell(f"{prefix}{i}", cell.width, cell.height, + cell.wells, cell.layers, cell.insets) for i in range(n)] + + nfet_comps = [nfet_c, nfet_c, nfet_o_c] + pfet_comps = [pfet_c, pfet_c, pfet_o_c] + nf_cells = [Cell.from_component(f"nf{i}", c, pdk) + for i, c in enumerate(nfet_comps)] + pf_cells = [Cell.from_component(f"pf{i}", c, pdk) + for i, c in enumerate(pfet_comps)] + + # Por defecto met3. Rails.above llega sola a la misma capa: el mimcap esta + # en la cima de la pila y no participa en una regla que consiste en subir + # un piso, asi que decide el mas alto de los que quedan. Pasar + # `rail_layer=None` devuelve esa eleccion automatica, y coincide. + # Con el MIM en met4/met5 la separacion del riel deja de decidirla met3. + # La correa de la placa inferior aterriza en el riel viniendo de met5, y + # esa pila deja un pad de met4 justo bajo el banco: contra la placa, que + # tambien es met4, manda MIM.1 y no la separacion de met3, que es cuatro + # veces menor. Sin esto la celda sale con una violacion por condensador. + holgura = None + if n_caps: + g_bot = pdk.layer_to_glayer(pdk.get_grule("capmet")["capmetbottom"]) + if g_bot != rail_layer: + holgura = float(pdk.get_grule("capmet")["min_separation"]) + rails_forzados = (Rails.minimum(pdk, rail_layer, width=supply_width, + clearance=holgura) + if rail_layer else None) + plan = plan_bands([Band("bottom", nf_cells + clones(cap, n_caps, "cap")), + Band("m5", [m5_cell]), + Band("top", pf_cells)], pdk, rails=rails_forzados, + rail_clearance=holgura) + lower, middle, upper = plan.bands + + top = Component(name=name) + nfets, pfets, caps = [], [], [] + for i in range(3): + nfets.append(_center_on(top << nfet_comps[i], + lower.plan.x[i] + nf_cells[i].width / 2, lower.y)) + for i in range(n_caps): + caps.append(_center_on(top << cap_c, + lower.plan.x[3 + i] + cap.width / 2, lower.y)) + m5_ref = _center_on(top << m5_c, middle.plan.x[0] + m5_cell.width / 2, middle.y) + for i in range(3): + pfets.append(_center_on(top << pfet_comps[i], + upper.plan.x[i] + pf_cells[i].width / 2, upper.y)) + + # --- each inverter: gate to gate, drain to drain ----------------------- + # The two links must not share a column. gate_S and drain_S both sit at + # x=0 of the device, so routing both from there overlays them and leaves + # every inverter diode-connected -- with the gate and drain on one net, + # which still looks like a correctly paired inverter to a careless check. + drain_routes = [] + for p, n in zip(pfets, nfets): + top << straight_route(pdk, p.ports[_GATE_MID.format(side="S")], + n.ports[_GATE_MID.format(side="N")], glayer1="met3") + drain_routes.append(top << c_route( + pdk, p.ports[_DRAIN_MID.format(side="E")], + n.ports[_DRAIN_MID.format(side="E")], cglayer="met3")) + + _wire_lif(pdk, top, nfets, caps, m5_ref, drain_routes, plan, + via_stack, rectangle, evaluate_bbox) + rails_y = _rails_bands(pdk, top, pfets, nfets, plan, via_stack, rectangle, + evaluate_bbox, supply_width, m5_ref, caps) + rails_y = _al_origen(top, rails_y) + _boundary(pdk, top, rectangle) + + top.add_port(name="IN", port=nfets[0].ports[_GATE_MID.format(side="W")]) + top.add_port(name="OUT", port=nfets[2].ports[_DRAIN_MID.format(side="W")]) + _pin_labels(pdk, top, rectangle, nfets, pfets, caps, m5_ref, rails_y, + drain_routes) + return top, {"nfets": nfets, "pfets": pfets, "caps": caps, "m5": m5_ref, + "rails": rails_y, + "plan": plan} + + +# Puertos del mimcap. La placa superior sale por `top_met_*`; la inferior no +# tiene puerto propio a nivel superior, se saca por la extension al sur, que +# la sube a la capa de la placa superior. Ver _cap_glayers. +_CAP_TOP = "top_met_{end}" +_CAP_BOT = "bot_via_S_top_met_{end}" + + +def _cap_glayers(pdk): + """(capa de la placa superior, capa de la inferior) segun el PDK. + + No se escriben a mano: gf180 ofrece el MIM entre met2/met3 (opcion A) o + entre met4/met5 (opcion B), y son excluyentes a nivel de proceso. Suponer + una deja el ruteo aterrizando dos niveles por debajo de la placa, sin via + que lo salve y sin error -- el condensador queda flotando. + """ + g = pdk.get_grule("capmet") + return (pdk.layer_to_glayer(g["capmettop"]), + pdk.layer_to_glayer(g["capmetbottom"])) + + +def _wire_lif(pdk, top, nfets, caps, m5_ref, drain_routes, plan, + via_stack, rectangle, evaluate_bbox): + """Fan-out and membrane node, both on met2. + + met3 is taken end to end by the per-inverter columns, so a horizontal run + there would touch every one of them. The obvious way out was met4, above + everything -- but the channel between the bottom band and M5 is empty on + met2 as well, and going down instead of up leaves met4 to the + mimcap, whose bottom plate lives there under option B. + + It also falls out of one rule instead of a decision per wire: met2 runs + horizontal, met3 runs vertical. Every crossing then lands on a different + layer by construction. + """ + m2 = pdk.get_glayer("met2") + m3 = pdk.get_glayer("met3") + width = float(pdk.get_grule("met2")["min_width"]) + width_v = float(pdk.get_grule("met3")["min_width"]) + climb = via_stack(pdk, "met2", "met3") + vw, vh = evaluate_bbox(climb) + + def strip(a, b, capa=None): + """Un tramo, con la capa que le toca por su direccion. + + Bajar TODO a met2 es lo que rompe: el tramo vertical que va de la + pista al banco de condensadores cruza las placas inferiores, que son + met2 y estan a VSS, y funde la membrana con el riel. La regla tiene + que ser por direccion, no por funcion. + """ + horizontal = abs(b[0] - a[0]) >= abs(b[1] - a[1]) + if capa is not None: + layer, w = pdk.get_glayer(capa), float(pdk.get_grule(capa)["min_width"]) + else: + layer = m2 if horizontal else m3 + w = width if horizontal else width_v + rect = top << rectangle( + size=pdk.snap_to_2xgrid([abs(b[0] - a[0]) + w, + abs(b[1] - a[1]) + w]), + layer=layer, centered=True) + _center_on(rect, pdk.snap_to_2xgrid((a[0] + b[0]) / 2), + pdk.snap_to_2xgrid((a[1] + b[1]) / 2)) + + def land(x, y, hasta="met3"): + pila = climb if hasta == "met3" else via_stack(pdk, "met2", hasta) + _center_on(top << pila, pdk.snap_to_2xgrid(x), pdk.snap_to_2xgrid(y)) + return (pdk.snap_to_2xgrid(x), pdk.snap_to_2xgrid(y)) + + # Both inter-band nets run in the gap between the bottom band and M5, + # on separate tracks. Routing the fan-out down at gate level instead -- + # the obvious choice, since that is where the gate ports are -- puts a + # met4 line straight across the nfets, exactly where their sources have + # to drop to VSS. The met3 column of a gate spans the whole cell height, + # so it can be tapped up here just as well. + lower, middle, upper = plan.bands + gap_lo = lower.y + lower.height / 2 + gap_hi = middle.y - middle.height / 2 + # El paso lo fija la PILA DE VIAS, no la pista: los cuadrados de 0.5 um + # de cada aterrizaje son lo que se acerca entre pistas vecinas, no los + # 0.28 del conductor. Dimensionarlo con el ancho de pista deja 0.18 um + # donde la capa pide mas. + sep = float(pdk.get_grule("met2")["min_separation"]) + pitch = vh + sep + # Y hay que separarse tambien de los BORDES del canal, no solo entre + # pistas: los anillos de guarda que lo limitan -- el de los nfets abajo, + # el de M5 arriba -- son met2, la misma capa en la que corren ahora estas + # dos. Con met4 eso daba igual y el par se centraba en el canal a secas; + # asi, el pad de una pila quedaba a 0.26 um del anillo donde M2.2a pide + # 0.28, y la membrana salia soldada al riel por dos centesimas. + # Y el borde se toma de los ANILLOS, no de la frontera de banda: la banda + # del planificador va mas arriba que el metal del anillo, asi que restarle + # margen a `gap_hi` deja la pila donde ya estaba. + margen = vh / 2 + sep + techo = float(m5_ref.ports["tie_S_top_met_S"].center[1]) + suelo = max(float(r.ports["tie_N_top_met_N"].center[1]) for r in nfets) + lo, hi = suelo + margen, techo - margen + if caps: + # El suelo lo marcan los nfet... salvo cuando el banco crece mas que + # ellos. Con Cm grande la placa superior sube por encima de la banda, + # y como la pista de membrana comparte SU capa, la separacion de esa + # capa decide donde puede ir la pista. Misma red, asi que el corto no + # es el problema: lo es la regla, que se mide igual entre poligonos + # del mismo nodo. + g_top = _cap_glayers(pdk)[0] + regla = pdk.get_grule(g_top) + placa = max(float(c.ports[_CAP_TOP.format(end="N")].center[1]) + for c in caps) + lo = max(lo, placa + float(regla["min_separation"]) + + float(regla["min_width"]) / 2) + # La pista de membrana sube a met3 sobre el primer condensador, asi que su + # pila queda ENCIMA de la placa inferior y MIM.1 se mide en vertical: 1.2 + # um desde el borde alto de la placa hasta el pad, no solo de lado. + # Sin margen vertical contra las placas: la pista de membrana corre por + # la capa de la placa SUPERIOR y la separacion de MIM.1 se mide contra la + # INFERIOR, que es otra capa. Quien si tiene que apartarse es la pila de + # la esquina, que atraviesa met4 -- y por eso va donde no hay cap debajo. + centro = (lo + hi) / 2 + y_fan = pdk.snap_to_2xgrid(centro - pitch / 2) + y_mem = pdk.snap_to_2xgrid(centro + pitch / 2) + + # --- fan-out: inv0 drives inv1 and inv2 -------------------------------- + # The drain's met3 column is NOT above its port -- c_route leaves eastward + # before climbing -- so its x comes from the route's own bbox. A gate's + # column does sit on its port, because that link is a straight run up the + # device centre. + src = land(float(drain_routes[0].xmax) - width / 2, y_fan) + loads = [land(float(nfets[i].ports[_GATE_MID.format(side="S")].center[0]), y_fan) + for i in (1, 2)] + strip(src, loads[-1]) + + # --- membrane: M5 drain, inv0 gate, cap top plates --------------------- + # inv0's gate column already crosses the M5 band on met3, and M5's drain + # is met2 directly under it, so one via joins them with no route at all. + gate_x = float(nfets[0].ports[_GATE_MID.format(side="S")].center[0]) + drain_n = m5_ref.ports[_DRAIN_MID.format(side="N")] + bridge = via_stack(pdk, "met2", "met3") + _center_on(top << bridge, pdk.snap_to_2xgrid(gate_x), + pdk.snap_to_2xgrid(float(drain_n.center[1]) + - evaluate_bbox(bridge)[1] / 2)) + + # --- feedback: inv1's output drives M5's gate -------------------------- + # M5's gate is a met2 strip running the length of the device, and inv1's + # drain column crosses it on met3 on its way between the bands. So this + # needs one via and no route at all, same as the membrane bridge above. + # inv0 and inv2 cross it too; only inv1 gets a via. + gate_m5 = m5_ref.ports[_GATE_MID.format(side="W")] + tap = via_stack(pdk, "met2", "met3") + _center_on(top << tap, + pdk.snap_to_2xgrid(float(drain_routes[1].xmax) - width / 2), + pdk.snap_to_2xgrid(float(gate_m5.center[1]))) + + # Las placas superiores YA son met3, asi que se unen entre si en su propia + # capa: nada de subir a met4 desde met3 para volver a bajar en el cap de + # al lado. Se ahorra una pila de vias por cap y la tira de met4. + # El puente corre por la capa de la placa superior, sea cual sea: asi se + # funde con ella y no hace falta via sobre el FuseTop. Con el MIM en + # met2/met3 esa capa es met3 y coincide con la vertical de la disciplina; + # con el MIM en met4/met5 el puente sube a met5 y el vertical le lleva. + g_top, _ = _cap_glayers(pdk) + m3 = pdk.get_glayer(g_top) + w3 = float(pdk.get_grule(g_top)["min_width"]) + izq, der = caps[0], caps[-1] + y_cap = float(izq.ports[_CAP_TOP.format(end="E")].center[1]) + # de borde OESTE del primero a borde ESTE del ultimo, para cruzar las tres + # placas por encima. Al reves la tira pasa por los huecos y no toca ninguna. + x0 = float(izq.ports[_CAP_TOP.format(end="W")].center[0]) + x1 = float(der.ports[_CAP_TOP.format(end="E")].center[0]) + # La subida a met3 va sobre el PRIMER condensador, a media placa. Llegando + # ya en met3 no hace falta via sobre el FuseTop: el vertical baja y se + # funde con la placa superior, que es de su misma capa. Y aterrizar en + # mitad de la placa reparte mejor que entrar por un borde. + # La via en si queda muy por encima del FuseTop -- en el canal -- asi que + # tampoco cae en la exclusion de conectividad del deck de LVS. + # Con la placa en la capa vertical basta con caer a media placa: el + # metal se funde y no hay via. En cualquier otro caso hace falta una, y + # tiene que quedar FUERA del FuseTop, asi que se busca el hueco entre la + # primera y la segunda placa. Con un solo cap no hay hueco y se sale por + # el oeste, mas alla del borde. + if True: + x_sube = pdk.snap_to_2xgrid(float(izq.center[0])) + else: + # La pila crea su propio pad en la capa de la placa inferior, asi que + # tiene que guardarle MIM.1 -- 1.2 um -- igual que cualquier otro + # poligono de esa capa. En el hueco entre placas no cabe: ese hueco ES + # 1.2 um, el minimo, y el pad quedaria a 0.35 de cada lado. Va al + # OESTE del banco, donde solo tiene una placa de la que apartarse y el + # canal entre inversores y condensadores esta vacio. + mim1 = float(pdk.get_grule("capmet")["min_separation"]) + x_sube = pdk.snap_to_2xgrid(float(izq.xmin) - mim1 - vw / 2) + # El puente se estira hasta donde baje la membrana, que puede quedar al + # oeste de la primera placa. + xa, xb = min(x0, x_sube), max(x1, x_sube) + puente = top << rectangle(size=pdk.snap_to_2xgrid([abs(xb - xa) + w3, w3]), + layer=m3, centered=True) + _center_on(puente, pdk.snap_to_2xgrid((xa + xb) / 2), + pdk.snap_to_2xgrid(y_cap)) + + # De ahi al inversor. La transicion de capa se hace FUERA del banco: sobre + # el cap no se puede bajar a met2 porque MIM.1 pide 1.2 um entre la placa + # inferior y cualquier otro met2, y la superior cae dentro de esa huella. + # El tramo largo de la membrana sube a met4. Es la red sensible de la + # celda -- el nodo de integracion que fija la frecuencia -- y met2 es el + # carril mas poblado. Arriba corre sola, con menos vecinos que le acoplen. + # Solo se puede cuando el MIM NO esta en met4: alli met4 es la placa + # inferior y la pista tendria que guardarle MIM.1 en todo su recorrido. + # La membrana corre por la capa de la placa superior. Asi llega al banco + # y se funde con las placas sin una sola via, que es lo que hacia la + # opcion A cuando esa capa era met3. Con el MIM arriba la pista sube a + # met5 y se lleva de paso la ventaja: sale del carril met2, que es el mas + # poblado, y la red sensible de la celda deja de tener vecinos que le + # acoplen. + via_mem = g_top + a = land(gate_x, y_mem, hasta=via_mem) + # El puente sigue en met3 hasta SALIR del banco por el oeste, y solo + # entonces baja a met2. Bajar encima del cap cruza la placa inferior, que + # es VSS: MIM.4 dentro de la huella y MIM.1 justo encima. Se entra por un + # extremo (la membrana, oeste) y se sale por el otro (VSS, sur). + # La transicion a met4 va en el HUECO entre el primer y el segundo cap, no + # encima de una placa. El deck de LVS descarta del grafo de conectividad + # cualquier via que solape el FuseTop -- + # via3_n_cap = via3.not(fusetop) + # -- asi que una pila puesta sobre la placa deja la membrana desconectada + # para el extractor aunque el metal se toque. + # La esquina lleva su propia pila: es donde el tramo horizontal de met2 + # entrega al vertical de met3. Esta al oeste del banco, fuera de la huella + # de MIM.1, asi que el met2 nunca llega a acercarse a una placa inferior. + # De ahi baja en met3 y entra directo en el puente. + # La esquina NO lleva pila: los dos tramos son la capa de la placa, asi + # que es un simple doblez. Ponerle una la hace atravesar met1..met4 y + # tocar lo que haya debajo -- que aqui es la columna del ultimo inversor. + esquina = (pdk.snap_to_2xgrid(x_sube), pdk.snap_to_2xgrid(y_mem)) + strip(a, esquina, capa=via_mem) + strip(esquina, (pdk.snap_to_2xgrid(x_sube), pdk.snap_to_2xgrid(y_cap)), + capa=via_mem) + + # Si la placa superior NO es la capa vertical de la disciplina, el tramo + # anterior deja la membrana dos niveles por debajo del puente y hay que + # salvar. La pila no puede caer sobre el FuseTop: el deck de LVS descarta + # del grafo de conectividad las vias que lo solapan -- + # via4_n_cap = via4.not(fusetop) + # -- asi que el condensador quedaria flotando para el extractor aunque el + # metal se toque. Va en el hueco entre placas, que es donde no hay ninguna. + # Sin pila al llegar: la pista YA es la capa de la placa. + + +def _pin_labels(pdk, top, rectangle, nfets, pfets, caps, m5_ref, rails, + drain_routes): + """Marcas de pin para que el LVS sepa como se llama cada red. + + En gf180 met*_pin y met*_label son la MISMA capa, y no conduce: la marca + tiene que caer ENCIMA de metal que ya exista, o el extractor no encuentra + conductor bajo el texto y la red sale sin nombre. Y centrada sobre el + punto, no alineada por un borde -- el centro de un puerto esta en el borde + de su metal y mirando hacia afuera, asi que alinear por ahi deja la marca + tangente. Es la leccion del PR 103 en diff_pair. + """ + lado = 0.27 + + def marca(glayer, texto, x, y): + capa = pdk.get_glayer(glayer + "_pin") + m = top << rectangle(size=(lado, lado), layer=capa, centered=True) + _center_on(m, pdk.snap_to_2xgrid(x), pdk.snap_to_2xgrid(y)) + top.add_label(text=texto, layer=capa, + position=(pdk.snap_to_2xgrid(x), pdk.snap_to_2xgrid(y))) + + riel = rails["glayer"] + ancho = float(pdk.get_grule(riel)["min_width"]) + medio = float(nfets[1].center[0]) + marca(riel, "Vdd", medio, rails["vdd"]) + marca(riel, "Vss", medio, rails["vss"]) + + # Iin es la membrana, y se marca sobre la placa superior del primer cap. + # La capa sale del PDK: met3 con el MIM en met2/met3, met5 con el en + # met4/met5. Escribirla a mano deja la marca flotando sobre una capa + # vacia, el extractor no encuentra conductor bajo el texto y la red sale + # sin nombre -- que es como el LVS lo reporta: "missing top-level pin". + g_top = _cap_glayers(pdk)[0] + p = caps[0].ports[_CAP_TOP.format(end="E")] + marca(g_top, "Iin", float(p.center[0]) - ancho, float(p.center[1])) + # spike y spike_neg van sobre la RUTA del drenador, no sobre el puerto + # _DRAIN_MID: en un fet de un dedo ese puerto comparte el x del centro + # del dispositivo con la puerta, y por ahi corre la columna de met3 que + # une las puertas -- que en el inversor 0 es la membrana. La marca caia + # ahi y nombraba la red equivocada. El drenador sale por el ESTE con un + # c_route, y ese es el conductor que hay que nombrar. + # + # netcheck no lo veia porque camina el metal y no mira etiquetas; el LVS + # lo reporto en su primera ejecucion como "extra top-level pin". + w3 = float(pdk.get_grule("met3")["min_width"]) + for nombre, ref, ruta in (("spike_neg", nfets[0], drain_routes[0]), + ("spike", nfets[2], drain_routes[2])): + d = ref.ports[_DRAIN_MID.format(side="E")] + marca("met3", nombre, float(ruta.xmax) - w3 / 2, float(d.center[1])) + + +def _into_metal_xy(port, w, h): + """Offset so a via stack lands on the port's metal rather than past it.""" + angle = (port.orientation or 0) % 360 + if 45 < angle < 135: + return (0.0, -h / 2) + if 225 < angle < 315: + return (0.0, +h / 2) + if 135 <= angle <= 225: + return (+w / 2, 0.0) + return (-w / 2, 0.0) + + +def _free_x(obstacles, lo, hi, need): + """Widest window in [lo, hi] that no obstacle x-interval covers. + + The VSS drop from M5 has to cross the bottom band, so where it can go + depends on what is placed there -- which moves when the caller changes + M5's length or the cap size. Compute the corridor, do not hardcode it. + """ + best, cur = None, lo + for a, b in sorted(obstacles): + if a - cur >= need and (best is None or a - cur > best[1] - best[0]): + best = (cur, a) + cur = max(cur, b) + if hi - cur >= need and (best is None or hi - cur > best[1] - best[0]): + best = (cur, hi) + return best + + +def _rails_bands(pdk, top, pfets, nfets, plan, via_stack, rectangle, + evaluate_bbox, supply_width=1.0, m5_ref=None, caps=()): + """VDD over the pfet band, VSS under the nfet band, on met4. + + The drops are short because the bands are ordered so each device type + faces its own rail: a pfet's source_N points up at VDD, an nfet's + source_S down at VSS. That ordering is not free -- it is why M5 sits + between the two halves of every inverter -- but it makes the supply + trivial, which is most of the wiring in the cell. + """ + # Supply runs carry the whole cell's current, so they are sized rather + # than left at minimum width -- both the rails and the drops that feed + # them. Minimum-width metal is for signals. + rails = plan.rails + layer = pdk.get_glayer(rails.glayer) + width = max(supply_width, float(pdk.get_grule(rails.glayer)["min_width"])) + + lower, _, upper = plan.bands + # rails.band was sized for a minimum-width rail. Keep the clearance it + # asked for and push the wider rail outward, rather than letting the extra + # width eat into the gap to the band. + grow = width - rails.width + y_vdd = pdk.snap_to_2xgrid(upper.y + upper.height / 2 + rails.band + grow + - width / 2) + y_vss = pdk.snap_to_2xgrid(lower.y - lower.height / 2 - rails.band - grow + + width / 2) + for y in (y_vdd, y_vss): + strap = top << rectangle( + size=pdk.snap_to_2xgrid([plan.width, width]), + layer=layer, centered=True) + _center_on(strap, pdk.snap_to_2xgrid(plan.width / 2), y) + + from glayout.routing.straight_route import straight_route + + tie_top = "met2" + clear = float(pdk.get_grule(rails.glayer)["min_separation"]) + width / 2 + + def tie_to_rail(ref, y_rail, end): + """Source -> guard ring -> rail. + + Not source -> rail directly. The ring sits immediately west of the + device and its west face is one port 4.1 um tall, so reaching it is a + short straight run across ground nobody else uses -- the gate column + goes up the middle and the drain column east of it. Dropping from the + source instead means threading a met4 line down the height of the + cell, which is what crossed the fan-out and membrane tracks and merged + them into the supply. + + """ + src = ref.ports[SOURCE.format(side="W")] + ring_in = ref.ports["tie_W_top_met_E"] + top << straight_route(pdk, src, ring_in) + + out = ref.ports[f"tie_{end}_top_met_{end}"] + # From met2, not met1. tie_layers=(horizontal, vertical) puts the + # ring's N and S edges on the first layer, so climbing from met1 here + # would add a via1 alongside the one the ring already has -- they land + # 0.258um apart and V1.2a wants 0.26. + climb1 = via_stack(pdk, tie_top, rails.glayer) + w1, h1 = evaluate_bbox(climb1) + dx, dy = _into_metal_xy(out, w1, h1) + x = pdk.snap_to_2xgrid(float(out.center[0]) + dx) + y = pdk.snap_to_2xgrid(float(out.center[1]) + dy) + _center_on(top << climb1, x, y) + rect = top << rectangle( + size=pdk.snap_to_2xgrid([width, abs(y_rail - y)]), + layer=layer, centered=True) + _center_on(rect, x, pdk.snap_to_2xgrid((y_rail + y) / 2)) + + def drop(port, y_rail, x=None, reach_first=False, desde="met2", + entrar=True): + """Salva de una capa a la del riel y baja con una correa ancha. + + `desde` es la capa del puerto de partida. Casi siempre met2, pero la + placa inferior del mimcap sale por la capa de la placa superior, que + puede estar por ENCIMA del riel -- con el MIM en met4/met5 la pila va + hacia abajo, no hacia arriba. via_stack quiere (inferior, superior), + asi que se ordenan. + + reach_first mantiene la correa en `desde` hasta el riel y salva alli. + Se usa donde la pila caeria dentro de un bloque: sobre un mimcap el + met3 de la pila pasa a 0.09 um de la placa superior, que es la + membrana -- un corto esperando, no solo un M3.2a. + """ + orden = ("met1", "met2", "met3", "met4", "met5") + a, b = sorted((desde, rails.glayer), key=orden.index) + climb = via_stack(pdk, a, b) + w, h = evaluate_bbox(climb) + dx, dy = _into_metal_xy(port, w, h) + if not entrar: + # El desplazamiento "hacia dentro del metal" busca que la pila + # quede sobre conductor. En la extension del mimcap eso empuja la + # correa hacia la placa superior, que esta a 0.6 um: el borde + # acaba rozandola y funde las dos placas. Aqui se sale recto. + dy = 0.0 + px = pdk.snap_to_2xgrid(float(port.center[0]) + dx if x is None else x) + py = pdk.snap_to_2xgrid(float(port.center[1]) + dy) + if reach_first: + strap = top << rectangle( + size=pdk.snap_to_2xgrid([width, abs(y_rail - py) + h]), + layer=pdk.get_glayer(desde), centered=True) + _center_on(strap, px, pdk.snap_to_2xgrid((y_rail + py) / 2)) + py = y_rail + _center_on(top << climb, px, py) + rect = top << rectangle(size=pdk.snap_to_2xgrid([width, abs(y_rail - py) + h]), + layer=layer, centered=True) + _center_on(rect, px, pdk.snap_to_2xgrid((y_rail + py) / 2)) + + def _correa_met1(port, y_rail, x): + """Correa vertical de alimentacion en met1, de un anillo al riel. + + met1 es la capa mas resistiva, asi que se compensa con ancho: esto + lleva corriente de bulk, no una señal. A cambio no estorba a nadie -- + en el canal met1 esta tan libre como met2 y no compite con la + disciplina de direcciones. + """ + ancho = max(width, 3 * float(pdk.get_grule("met1")["min_width"])) + py = pdk.snap_to_2xgrid(float(port.center[1])) + px = pdk.snap_to_2xgrid(x) + strap = top << rectangle( + size=pdk.snap_to_2xgrid([ancho, abs(y_rail - py)]), + layer=pdk.get_glayer("met1"), centered=True) + _center_on(strap, px, pdk.snap_to_2xgrid((y_rail + py) / 2)) + # y sube al riel solo al final + remate = via_stack(pdk, "met1", rails.glayer) + _center_on(top << remate, px, pdk.snap_to_2xgrid(y_rail)) + + if m5_ref is not None: + # M5 sits in the middle band but its source and bulk belong to VSS at + # the bottom, so this drop has to cross the bottom band. Send it down + # the widest gap between the blocks placed there. + top << straight_route(pdk, m5_ref.ports[SOURCE.format(side="W")], + m5_ref.ports["tie_W_top_met_E"]) + blocked = [(float(r.bbox[0][0]) - clear, float(r.bbox[1][0]) + clear) + for r in list(nfets) + list(caps)] + ring = m5_ref.ports["tie_S_top_met_S"] + lo = float(ring.center[0]) - ring.width / 2 + width / 2 + hi = float(ring.center[0]) + ring.width / 2 - width / 2 + window = _free_x(blocked, lo, hi, width) + if window is not None: + # Esta correa es la unica que cruza el canal, y por ahi corren + # ahora la membrana y el fan-out en met2. Va por met1: la regla es + # met1 y met3 verticales, met2 horizontal, asi que una vertical no + # tiene nada que hacer en met2. Y el anillo ya lleva met1 debajo + # -- tie_layers=(met2, met1) -- de modo que en el origen no hace + # falta ninguna via nueva. + _correa_met1(ring, y_vss, (window[0] + window[1]) / 2) + else: + # No corridor -- a short M5 shrinks the cell until the bottom band + # fills it. Hop to the nfet's ring instead: both are pwell taps on + # VSS, that ring is already strapped to the rail, and it sits + # directly below by construction, so this route always exists. + # Longer electrically than going straight to the rail, which is + # why it is the fallback and not the rule. + near = min(nfets, key=lambda r: abs(float(r.center[0]) + - float(ring.center[0]))) + up = near.ports["tie_N_top_met_N"] + x = pdk.snap_to_2xgrid(float(up.center[0])) + # Both ports sit ON the edge of their ring, so a rectangle drawn + # between the two centres merely abuts them -- and after snapping + # it can fall a few nm short and leave a gap that reads as met2 + # spacing. Overrun into each ring instead. + # En met1, igual que la correa del corredor: este salto cruza el + # canal de lado a lado, y en met2 se lleva por delante la membrana + # y el fan-out. El anillo de M5 ya tiene su recorrido en met1, asi + # que arriba enchufa directo; abajo hace falta una via porque el + # borde norte del anillo del nfet si es met2. + solape = float(pdk.get_grule("met1")["min_width"]) + y0 = float(up.center[1]) - solape + y1 = float(ring.center[1]) + solape + hop = top << rectangle( + size=pdk.snap_to_2xgrid([width, abs(y1 - y0)]), + layer=pdk.get_glayer("met1"), centered=True) + _center_on(hop, x, pdk.snap_to_2xgrid((y0 + y1) / 2)) + # Sin via propia: el anillo del nfet ya lleva met1 bajo todo su + # perimetro, asi que el salto entra por su misma capa. Ponerle una + # aqui la deja a 0.258 um de las que el anillo ya tiene, y V1.2a + # pide 0.26 -- la misma trampa que documenta tie_to_rail. + + for ref in caps: + # bottom plate to VSS; the top plate is already on the membrane + # La placa inferior sale por la extension sur, ya subida a la capa + # de la placa superior. Con el MIM arriba eso queda por encima del + # riel y la pila baja; drop lo resuelve por si sola. + g_top, _ = _cap_glayers(pdk) + drop(ref.ports[_CAP_BOT.format(end="S")], y_vss, + reach_first=True, desde=g_top, entrar=False) + + for ref in pfets: + tie_to_rail(ref, y_vdd, "N") + for ref in nfets: + tie_to_rail(ref, y_vss, "S") + + return {"vdd": y_vdd, "vss": y_vss, "width": width, + "glayer": rails.glayer} + +def _boundary(pdk, top, rectangle): + """Marca el contorno del macro en la capa (0,0). + + LibreLane no deduce donde acaba un bloque mirando su metal: lee esta + capa. No se fabrica -- es una marca de contorno, como el PR_boundary de + otros flujos -- y el equipo la ha fijado para todas las celdas del + D14_topcell. + + Se dibuja despues de llevar la celda al origen, asi que va de (0,0) a la + esquina opuesta y coincide exactamente con lo que el LEF va a declarar. + """ + ancho = float(top.xmax - top.xmin) + alto = float(top.ymax - top.ymin) + marco = top << rectangle(size=(ancho, alto), layer=(0, 0), centered=False) + marco.movex(float(top.xmin)).movey(float(top.ymin)) + return marco + + +def _al_origen(top, rails_y): + """Lleva la esquina inferior izquierda de la celda a (0, 0). + + Un macro se integra por su LEF, que declara un origen. Si la geometria + vive en (0, -2.21) y el LEF dice (0, 0), la herramienta coloca el bloque + creyendo una cosa y el metal aparece desplazado: pistas que no conectan + y violaciones en el borde, y no al colocar sino despues de rutear. + + Va aqui, despues de los rieles y antes de los puertos y las etiquetas: + las referencias arrastran sus puertos al moverse, pero `rails_y` lleva + coordenadas absolutas en float que no se enteran, asi que se corrigen a + mano. Las etiquetas de pin todavia no existen y se dibujaran ya en su + sitio. + """ + dx, dy = top.bbox[0] + if abs(dx) < 1e-9 and abs(dy) < 1e-9: + return rails_y + for ref in top.references: + ref.movex(-dx).movey(-dy) + corregido = dict(rails_y) + for k in ("vdd", "vss"): + corregido[k] = float(rails_y[k]) - dy + return corregido + + +# Los inversores del lazo van al minimo: el solver no los dimensiona porque +# no fijan nada del comportamiento, a diferencia de M5 y del buffer. +INVERSOR_MINIMO = dict(width=0.22, length=0.28) + +FET_POR_DEFECTO = dict(multipliers=1, fingers=1, with_substrate_tap=False, + with_dummy=False, tie_layers=("met2", "met1"), sd_rmult=1) + +CAPS = 3 # la membrana se reparte en tres MIM, uno por hueco de banda + +# MIM.8a: el area del FuseTop no puede bajar de 25 um2, y el `size` de mimcap +# ES el FuseTop, asi que 5 um de lado es el minimo absoluto de un MIM. +LADO_MINIMO = 5.0 + + +# Paso al que sale dibujada una dimension centrada: el GDS se escribe a 0.005 +# um y tanto la placa del MIM como el canal de un FET van centrados, asi que +# cada borde cae en media unidad. `snap_to_2xgrid` no basta porque usa +# `pdk.grid_size`, que en gf180 dice 0.001. +REJILLA_DIBUJO = Decimal("0.01") + + +def en_rejilla(valor: float) -> float: + """Dimension [um] que se dibuja exactamente como se pide. + + Sin esto lo pedido y lo dibujado divergen en menos de una centesima y el + LVS lo ve: un MIM de 5.864 sale de 5.87 y su area no es la que declara el + netlist; un M5 de 1.671 sale de 1.68 y el comparador marca el ancho. + + Se redondea hacia ARRIBA, como `snap_to_2xgrid`, para no perder ni + capacidad ni corriente respecto a lo que el solver pidio. + """ + return float(REJILLA_DIBUJO + * (Decimal(str(valor)) / REJILLA_DIBUJO).quantize( + 1, rounding=ROUND_UP)) + + +def from_design(pdk, design, mim: str = mim_pdk.POR_DEFECTO, + fet: dict | None = None, rail_layer: Optional[str] = RIEL_POR_DEFECTO, + name: str = "lif"): + """Construye la celda que describe un NeuronDesign. + + Es la union entre la capa que resuelve el comportamiento y la que dibuja. + Devuelve (componente, handles, notas); las notas dicen que se perdio al + pasar de un numero continuo a geometria, que es donde se va la precision. + + El unico parametro que no sale del diseño es `mim`: cual de las tres + opciones de MIM corre la fabrica es una decision de proceso, y cambia + cuanta area hace falta para la misma Cm. Ver mim.py. + """ + fet = dict(FET_POR_DEFECTO if fet is None else fet) + p = design.params + notas = [] + + # Repartir la membrana en varios MIM ahorra area muerta, pero cada uno + # tiene que seguir siendo legal: se baja el numero hasta que el lado + # llegue al minimo. + n = CAPS + while n > 1 and mim_pdk.lado_para(p["Cm"], mim=mim, n=n) < LADO_MINIMO: + n -= 1 + lado = mim_pdk.lado_para(p["Cm"], mim=mim, n=n) + if lado < LADO_MINIMO: + notas.append(Note( + Severity.WARNING, "Cm", + "%.1f fF cabe en menos de un MIM minimo; se usa uno de %.1f um " + "y la membrana sube a %.1f fF" + % (p["Cm"], LADO_MINIMO, mim_pdk.capacidad(LADO_MINIMO, mim, 1)), + chain="MIM.8a: area de FuseTop >= 25 um2")) + lado = LADO_MINIMO + lado_real = en_rejilla(lado) + cm_real = mim_pdk.capacidad(lado_real, mim=mim, n=n) + error = (cm_real - p["Cm"]) / p["Cm"] + notas.append(Note( + Severity.INFO if abs(error) < 0.02 else Severity.WARNING, "Cm", + "pedida %.1f fF -> %d MIM de %.3f um de lado = %.1f fF (%+.1f%%), " + "con %s" % (p["Cm"], n, lado_real, cm_real, 100 * error, + mim_pdk.modelo(pdk, mim)), + chain="snap a rejilla del lado del MIM")) + + # Las dimensiones que de verdad se dibujan. El netlist de referencia lee + # de aqui, no de design.params: si declarase lo pedido, el LVS marcaria la + # diferencia de rejilla como un ancho que no casa. + dims = {k: en_rejilla(p[k]) for k in ("W_M5", "L_M5", "W_M7M8")} + dims.update(W_inv=en_rejilla(INVERSOR_MINIMO["width"]), + L_inv=en_rejilla(INVERSOR_MINIMO["length"])) + + top, handles = lif_cell( + pdk, + inverter=dict(INVERSOR_MINIMO, width=dims["W_inv"], + length=dims["L_inv"], **fet), + m5=dict(width=dims["W_M5"], length=dims["L_M5"], **fet), + output_inverter=dict(width=dims["W_M7M8"], + length=dims["L_inv"], **fet), + cap_size=lado_real, n_caps=n, rail_layer=rail_layer, name=name) + handles["dims"] = dims + handles["Cm_real"] = cm_real + handles["mim"] = mim_pdk.modelo(pdk, mim) + handles["cap_lado"] = lado_real + return top, handles, notas diff --git a/designs/scripts/lif_design/check.py b/designs/scripts/lif_design/check.py new file mode 100644 index 0000000..58b34ef --- /dev/null +++ b/designs/scripts/lif_design/check.py @@ -0,0 +1,281 @@ +"""Build the LIF cell at several sizes and check each one is really a LIF. + +Run inside the container: + + GLAYOUT_BACKEND=gdstk python check.py + +The dimensions come from the characterisation layer, so the generator has to +hold up across the range those laws produce, not just at one point. For every +size this runs DRC *with the MIM rules enabled* -- the gf180 deck defaults +mim_option to "Nan" and silently skips the whole MIM section otherwise -- and +then checks the six nets of the LIF topology by walking the metal. + +What is being asserted, per the netlist: + + integration M5 drain, inv0 gate, the cap top plates + spike_neg inv0 drain, inv1 gate, inv2 gate + spike/reset inv1 drain, M5 gate <- the reset feedback + spike inv2 drain <- the cell output + VDD pfet sources, the top rail + VSS nfet sources, M5 source, the cap bottom plates, bottom rail + +A net coming out merged with another is a short; one splitting in two is an +open. Both show up here as a set that does not match. +""" +import json +import os +import pathlib +import re +import subprocess +import sys + +# El paquete se importa por su sitio en el disco, no por una ruta fija: asi +# esto corre igual desde el repo, desde un notebook o dentro del contenedor. +AQUI = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(AQUI.parent)) + +from glayout import gf180 # noqa: E402 + +from lif_design.build import (lif_cell, _cap_glayers, # noqa: E402 + _CAP_TOP, _CAP_BOT) + +def _deck(): + """El deck de DRC que trae el propio glayout instalado. + + Derivado de donde este el paquete, no una ruta fija: asi esto corre en la + maquina de cualquiera y no solo en el contenedor donde se escribio. + GF180_DRC lo sobreescribe si hace falta apuntar a otro. + """ + import glayout + return str(pathlib.Path(glayout.__file__).parent + / "pdk" / "gf180_mapped" / "gf180mcu.drc") + + +DECK = os.environ.get("GF180_DRC") or _deck() +NETCHECK = str(AQUI / "netcheck.py") +SALIDA = os.environ.get("LIF_OUT", "/tmp") +# LIF_RAIL_LAYER fuerza la capa de los rieles en todo el barrido, para poder +# medir la celda entera en un stack de 3 metales. Sin la variable NO se pasa +# rail_layer, para que valga el defecto del paquete: pasar None pide la +# eleccion automatica, y esa mira la capa mas alta de la fila. Con el MIM en +# met4/met5 la mas alta es la placa superior y no queda piso encima, asi que +# levanta. La regla es conservadora -- los rieles corren por los extremos y +# no cruzan el banco -- pero no lo sabe. +RIEL = os.environ.get("LIF_RAIL_LAYER") or None +_RIEL_KW = {"rail_layer": RIEL} if RIEL else {} +FET = dict(multipliers=1, fingers=1, with_substrate_tap=False, + with_dummy=False, tie_layers=("met2", "met1"), sd_rmult=1) + +CASOS = [ + ("base", dict(w_inv=0.22, l_inv=0.28, w_m5=1.25, l_m5=50.0, cap=5.0)), + ("inv anchos", dict(w_inv=1.00, l_inv=0.28, w_m5=1.25, l_m5=50.0, cap=5.0)), + ("M5 corto", dict(w_inv=0.22, l_inv=0.28, w_m5=1.25, l_m5=25.0, cap=5.0)), + ("M5 ancho", dict(w_inv=0.22, l_inv=0.28, w_m5=3.50, l_m5=50.0, cap=5.0)), + ("cap grande", dict(w_inv=0.22, l_inv=0.28, w_m5=1.25, l_m5=50.0, cap=8.0)), + ("todo grande", dict(w_inv=1.00, l_inv=0.50, w_m5=3.50, l_m5=50.0, cap=8.0)), + # esquina baja: M5 en los dos minimos a la vez. El anillo de M5 se queda + # mas estrecho que el paso de las pistas, que es donde el ruteo aprieta. + ("todo minimo", dict(w_inv=0.22, l_inv=0.28, w_m5=0.22, l_m5=20.0, cap=5.0)), +] + +def esperado(n_caps): + """Las seis redes del LIF. El numero de MIM no es fijo: baja cuando la + membrana es pequeña, porque MIM.8a impide un FuseTop de menos de 25 um2.""" + arriba = {"cap%d_arriba" % i for i in range(n_caps)} + abajo = {"cap%d_abajo" % i for i in range(n_caps)} + return { + "membrana": {"M5_drain", "nfet0_gate", "pfet0_gate"} | arriba, + "spike_neg": {"nfet0_drain", "pfet0_drain", + "nfet1_gate", "pfet1_gate", "nfet2_gate", "pfet2_gate"}, + "realimenta": {"nfet1_drain", "pfet1_drain", "M5_gate"}, + "salida": {"nfet2_drain", "pfet2_drain"}, + "VDD": {"riel_VDD", "pfet0_source", "pfet1_source", "pfet2_source"}, + "VSS": {"riel_VSS", "M5_source", "nfet0_source", "nfet1_source", + "nfet2_source"} | abajo, + } + +MET = {"met1": 34, "met2": 36, "met3": 42, "met4": 46, "met5": 81} + + +def sondas(handles, bbox): + r = handles["rails"] + L = MET[r["glayer"]] + mid = float(bbox[0][0] + bbox[1][0]) / 2 + pr = {"riel_VDD": [L, 0, mid, r["vdd"]], "riel_VSS": [L, 0, mid, r["vss"]]} + # Las capas del MIM salen del PDK: gf180 lo ofrece en met2/met3 o en + # met4/met5 y son excluyentes. Con los numeros escritos a mano la sonda + # cae en una capa vacia y la red sale "partida" sin que nada este mal. + g_top, g_bot = _cap_glayers(gf180) + l_top, l_bot = MET[g_top], MET[g_bot] + for i, c in enumerate(handles["caps"]): + q = c.ports[_CAP_TOP.format(end="S")] + pr["cap%d_arriba" % i] = [l_top, 0, float(q.center[0]), float(q.center[1]) + 0.25] + # la placa inferior se pincha en su metal, bajo la extension sur + b = c.ports[_CAP_BOT.format(end="S")] + pr["cap%d_abajo" % i] = [l_bot, 0, float(b.center[0]), float(b.center[1]) + 0.25] + for tag, lst in (("nfet", handles["nfets"]), ("pfet", handles["pfets"])): + for i, ref in enumerate(lst): + for nombre, puerto in (("source", "multiplier_0_source_W"), + ("drain", "multiplier_0_drain_W")): + p = ref.ports[puerto] + pr["%s%d_%s" % (tag, i, nombre)] = [ + 36, 0, float(p.center[0]) + 0.2, float(p.center[1])] + g = ref.ports["multiplier_0_gate_S"] + pr["%s%d_gate" % (tag, i)] = [ + 36, 0, float(g.center[0]), float(g.center[1]) + 0.15] + for nombre, puerto in (("source", "multiplier_0_source_W"), + ("drain", "multiplier_0_drain_W"), + ("gate", "multiplier_0_gate_W")): + p = handles["m5"].ports[puerto] + pr["M5_%s" % nombre] = [36, 0, float(p.center[0]) + 0.2, float(p.center[1])] + return pr + + +def _mim_option(pdk): + """A o B, segun donde ponga el PDK las placas del MIM. + + No se fija a mano: el deck de la opcion equivocada busca la placa + inferior en la capa que no es, `mim_virtual` sale vacio, y MIM.3 acusa + al condensador de no tener placa. Un falso positivo que parece un fallo + de layout. + """ + bottom = pdk.layer_to_glayer(pdk.get_grule("capmet")["capmetbottom"]) + return "A" if bottom == "met2" else "B" + + +def _metal_level(pdk): + """Cuantos metales tiene la pila, como los nombra el deck. + + Hay que pasarlo: el deck del PDK asume 5LM si falta, pero la copia que + trae glayout asume 6LM. Con 6LM el deck cree que la cima es metaltop, asi + que `topmin1_via` pasa a ser via4 -- que es justo la via del MIM en + opcion B -- y MIMTM.10 acusa al condensador de tener vias prohibidas + dentro de si mismo. Ciento y pico violaciones que no existen. + """ + n = sum(1 for i in range(1, 7) if "met%d" % i in pdk.glayers) + return "%dLM" % n + + +def drc(gds, tag, pdk=gf180): + rep = "%s/%s.lyrdb" % (SALIDA, tag) + subprocess.run(["klayout", "-b", "-r", DECK, "-rd", "input=" + gds, + "-rd", "report=" + rep, + "-rd", "mim_option=" + _mim_option(pdk), + "-rd", "metal_level=" + _metal_level(pdk)], + capture_output=True) + texto = open(rep).read() + cuenta = {} + for cat in re.findall(r"'([^']+)'", texto): + cuenta[cat] = cuenta.get(cat, 0) + 1 + return texto.count(""), cuenta + + +def redes(gds, probes): + salida = subprocess.run(["klayout", "-b", "-r", NETCHECK, "-rd", "gds=" + gds, + "-rd", "probes=" + probes], + capture_output=True, text=True).stdout + grupos = [] + for linea in salida.splitlines(): + m = re.match(r"\s+(\S+)\s+(.+)$", linea) + if m and "#" in m.group(1): + grupos.append(set(m.group(2).split())) + return grupos + + +# Las cuatro primeras barren frecuencia por la misma ruta del solver (f fija +# con Iex fija). Las tres siguientes entran por rutas distintas Y salen con +# geometrias que ninguna de las anteriores produce -- ese es el criterio para +# estar aqui, porque cada caso cuesta un DRC completo. +# Un objetivo de ganancia (freq_range e iex_range los dos en rango) NO esta: +# recorre codigo distinto en el solver pero resuelve por (f_hi, iex_hi), asi +# que da el mismo GDS que el caso de 800 kHz. Vive en el notebook. +ESPECIFICACIONES = [ + ("200 kHz", dict(freq_range=200, iex_range=100)), + ("300 kHz", dict(freq_range=300, iex_range=100)), + ("800 kHz", dict(freq_range=800, iex_range=100)), + ("2000 kHz", dict(freq_range=2000, iex_range=100)), + # el umbral entra en juego y arrastra Cm, o sea el numero de MIM + ("umbral 2.0V", dict(freq_range=800, iex_range=100, vth=2.0)), + # extremo bajo de corriente verificado: saca W_M5 casi al minimo (0.222) + ("Iex 5 nA", dict(freq_range=300, iex_range=5)), + # unica ruta que dimensiona el bufer de salida: W_M7M8 sube y el + # inversor de salida cambia de tamaño en el layout + ("carga 800fF", dict(freq_range=800, iex_range=100, c_load=800)), +] + + +def desde_especificacion(): + """El camino completo: kHz y nA -> geometria -> GDS verificado.""" + from lif_design.spec import NeuronSpec + from lif_design.solver import design as resolver + from lif_design.build import from_design + + fallos = 0 + print("\n%-12s %-16s %5s %s" % ("spec", "caja um", "DRC", "topologia")) + print("-" * 74) + for nombre, kw in ESPECIFICACIONES: + tag = "spec_" + re.sub(r"\W+", "_", nombre) + d = resolver(NeuronSpec(**kw)) + top, h, _ = from_design(gf180, d, name=tag, **_RIEL_KW) + gds = "%s/%s.gds" % (SALIDA, tag) + top.write_gds(gds) + bb = top.bbox + n, cats = drc(gds, tag) + json.dump(sondas(h, bb), open("%s/%s.json" % (SALIDA, tag), "w")) + grupos = redes(gds, "%s/%s.json" % (SALIDA, tag)) + malas = [red for red, quiero in esperado(len(h["caps"])).items() + if not any(g == quiero for g in grupos)] + if malas or n: + fallos += 1 + print("%-12s %6.2f x %6.2f %5s %s" + % (nombre, bb[1][0] - bb[0][0], bb[1][1] - bb[0][1], + n if not n else "%d %s" % (n, cats), + " ".join(malas) if malas else "ok (%d MIM)" % len(h["caps"]))) + return fallos + + +def main(): + fallos = 0 + print("%-12s %-16s %5s %s" % ("caso", "caja um", "DRC", "topologia")) + print("-" * 74) + for nombre, kw in CASOS: + tag = "chk_" + re.sub(r"\W+", "_", nombre) + try: + top, h = lif_cell( + gf180, + inverter=dict(width=kw["w_inv"], length=kw["l_inv"], **FET), + m5=dict(width=kw["w_m5"], length=kw["l_m5"], **FET), + cap_size=kw["cap"], name=tag, **_RIEL_KW) + except Exception as exc: + print("%-12s no genera: %s" % (nombre, str(exc)[:52])) + fallos += 1 + continue + + gds = "%s/%s.gds" % (SALIDA, tag) + top.write_gds(gds) + bb = top.bbox + n, cats = drc(gds, tag) + pr = sondas(h, bb) + json.dump(pr, open("%s/%s.json" % (SALIDA, tag), "w")) + grupos = redes(gds, "%s/%s.json" % (SALIDA, tag)) + + malas = [] + for red, quiero in esperado(len(h["caps"])).items(): + if not any(g == quiero for g in grupos): + encontrado = next((g for g in grupos if g & quiero), set()) + malas.append("%s(%s)" % ( + red, "+".join(sorted(encontrado - quiero)) or "partida")) + estado = "ok" if not malas and n == 0 else " ".join(malas) or "DRC" + if malas or n: + fallos += 1 + print("%-12s %6.2f x %6.2f %5s %s" + % (nombre, bb[1][0] - bb[0][0], bb[1][1] - bb[0][1], + n if not n else "%d %s" % (n, cats), estado)) + fallos += desde_especificacion() + print("\n%s" % ("todos los casos pasan" if not fallos + else "%d caso(s) con problemas" % fallos)) + return 1 if fallos else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/designs/scripts/lif_design/example.py b/designs/scripts/lif_design/example.py new file mode 100644 index 0000000..2864300 --- /dev/null +++ b/designs/scripts/lif_design/example.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Ejemplos de uso del sistema de diseño. Ejecutable directamente: + + python sch/lif/design/example.py + +Para verificar por simulacion hace falta ngspice y el contenedor: + + docker exec capimagics_x bash -lc \\ + "python3.10 /foss/repo/sch/lif/design/example.py --verify" + +Para usarlo desde otro script en cualquier ruta, instalar el paquete: + + cd sch/lif && pip install -e . + +y luego basta con `from design import NeuronSpec, design`. +""" +import sys +from pathlib import Path + +# permite ejecutar este archivo sin haber instalado el paquete; si ya esta +# instalado con pip install -e, esta linea no cambia nada +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from design import NeuronSpec, design, verify # noqa: E402 + +TB_DIR = Path(__file__).resolve().parent.parent / "tb" + + +def ejemplo_1_default(): + """Sin objetivos: devuelve el punto nominal, el unico simulado.""" + print("\n1. 'haz una neurona y ya'") + d = design(NeuronSpec()) + print(f" {d.params}") + print(f" f a 100 nA: {d.predicted['f a 100 nA [kHz]']} kHz") + + +def ejemplo_2_rangos(): + """Lo habitual: rango de entrada -> rango de salida.""" + print("\n2. rango de corriente -> rango de frecuencia") + d = design(NeuronSpec(iex_range=(20, 200), freq_range=(300, 1200))) + print(f" {d.params}") + print(f" k = {d.predicted['k [kHz/nA]']} kHz/nA") + print(f" f = {d.predicted['f en el rango [kHz]']} kHz") + print(f" requisito: {d.requirements['impedancia de fuente']}") + + +def ejemplo_3_hibrido(): + """El diseñador ya calculo W; que se respete y se resuelva el resto.""" + print("\n3. hibrido: W fija, el resto libre") + d = design(NeuronSpec(W_M5=1.0, freq_range=(500, 500))) + print(f" {d.params}") + for n in d.notes: + print(f" {n}") + + +def ejemplo_4_conflicto(): + """Objetivo incompatible con las dimensiones fijadas.""" + print("\n4. conflicto resoluble (los objetivos mandan)") + d = design(NeuronSpec(W_M5=1.0, L_M5=41, freq_range=(2000, 2000))) + print(f" {d.params} ok={d.ok}") + for n in d.warnings: + print(f" [{n.subject}] {n.message}") + + +def ejemplo_5_imposible(): + """Contradiccion sin salida: se explica la cadena causal.""" + print("\n5. imposible fisico (f baja + Vth alto estan acoplados)") + d = design(NeuronSpec(freq_range=(200, 200), vth=2.5)) + print(f" ok={d.ok}") + for n in d.errors: + print(f" {n.message}") + print(f" cadena: {n.chain}") + + +def ejemplo_6_verify(): + """Cierra el lazo: netlist -> ngspice -> medida -> comparacion.""" + print("\n6. verificacion por simulacion") + d = design(NeuronSpec(freq_range=(800, 800))) + print(f" diseño: {d.params}") + r = verify(d, iex_na=100.0, workdir=TB_DIR) + print(f" {r.status}") + for k in r.predicted: + m = r.measured.get(k) + if m: + print(f" {k:8s} pred {r.predicted[k]:8.2f} med {m:8.2f}" + f" {r.errors_pct.get(k, 0):+6.1f}%") + + +if __name__ == "__main__": + ejemplo_1_default() + ejemplo_2_rangos() + ejemplo_3_hibrido() + ejemplo_4_conflicto() + ejemplo_5_imposible() + if "--verify" in sys.argv: + ejemplo_6_verify() + else: + print("\n(usa --verify para el ejemplo 6, que necesita ngspice)") diff --git a/designs/scripts/lif_design/floorplan.py b/designs/scripts/lif_design/floorplan.py new file mode 100644 index 0000000..030166d --- /dev/null +++ b/designs/scripts/lif_design/floorplan.py @@ -0,0 +1,117 @@ +"""Spacing between blocks in a row, from the nets that cross each gap. + +The gap between two adjacent blocks is not a constant: it has to hold the +routes that pass *through* it. A net between neighbours needs no channel, but +one that reaches over an intermediate block does, and so does one that runs +backwards. Sizing every gap the same way either wastes area or leaves the +router without room. + + gaps = plan_row(blocks, nets, pdk) + +Blocks keep the order they are given -- this only computes spacing. It also +does not place anything: it returns the numbers, and the layout code applies +them. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Block: + """A placed cell: its name and how wide it is.""" + name: str + width: float # um + height: float = 0.0 # um, only used for the row height + + +@dataclass(frozen=True) +class Net: + """A connection between block ports, as `block.port`.""" + name: str + endpoints: tuple[str, ...] + + def blocks(self) -> set[str]: + return {e.split(".", 1)[0] for e in self.endpoints} + + +@dataclass +class RowPlan: + """What plan_row worked out.""" + order: list[str] + gaps: list[float] = field(default_factory=list) # len == len(order)-1 + x: list[float] = field(default_factory=list) # left edge of each block + width: float = 0.0 + tracks: list[int] = field(default_factory=list) # nets crossing each gap + notes: list[str] = field(default_factory=list) + + def report(self) -> str: + lines = [f"row: {self.width:.2f} um wide"] + for i, name in enumerate(self.order): + lines.append(f" x={self.x[i]:8.2f} {name}") + if i < len(self.gaps): + lines.append(f" {'gap':>9} {self.gaps[i]:6.2f} um " + f"({self.tracks[i]} net{'s' if self.tracks[i] != 1 else ''} crossing)") + lines.extend(f" note: {n}" for n in self.notes) + return "\n".join(lines) + + +def _crossings(nets, order, gap_index): + """Nets that span the gap between order[gap_index] and order[gap_index+1]. + + A net crosses if it touches a block on each side. Neighbour-to-neighbour + connections do touch both sides, but they terminate at the gap rather than + running through it, so they are not counted as needing a channel. + """ + left = set(order[:gap_index + 1]) + right = set(order[gap_index + 1:]) + out = [] + for net in nets: + touched = net.blocks() + if not (touched & left and touched & right): + continue + # only the two blocks flanking the gap -> it terminates here + if touched <= {order[gap_index], order[gap_index + 1]}: + continue + out.append(net) + return out + + +def plan_row(blocks: list[Block], nets: list[Net], pdk, + glayer: str = "met2", min_gap: float | None = None) -> RowPlan: + """Lay blocks left to right, sizing each gap for the nets crossing it. + + blocks keep their given order. min_gap defaults to the PDK's metal + separation, which is the floor even where nothing crosses. + """ + order = [b.name for b in blocks] + plan = RowPlan(order=order) + + rule = pdk.get_grule(glayer) + pitch = rule["min_width"] + rule["min_separation"] + floor = min_gap if min_gap is not None else pdk.util_max_metal_seperation() + + unknown = {e.split(".", 1)[0] for n in nets for e in n.endpoints} - set(order) + if unknown: + plan.notes.append( + f"nets reference blocks that are not in the row: {sorted(unknown)}") + + for i in range(len(blocks) - 1): + crossing = _crossings(nets, order, i) + needed = len(crossing) * pitch + gap = max(floor, needed) + plan.tracks.append(len(crossing)) + plan.gaps.append(gap) + if crossing: + plan.notes.append( + f"gap {order[i]}->{order[i+1]}: {len(crossing)} x {pitch:.3f} um " + f"for {', '.join(n.name for n in crossing)}") + + x = 0.0 + for i, b in enumerate(blocks): + plan.x.append(x) + x += b.width + if i < len(plan.gaps): + x += plan.gaps[i] + plan.width = x + return plan diff --git a/designs/scripts/lif_design/laws.py b/designs/scripts/lif_design/laws.py new file mode 100644 index 0000000..5e4f9ed --- /dev/null +++ b/designs/scripts/lif_design/laws.py @@ -0,0 +1,240 @@ +"""Leyes empiricas de la neurona LIF (GF180MCU, entrada de corriente). + +Cada ley trae su despeje inverso, porque el diseñador puede fijar cualquier +variable y pedir que se resuelvan las demas. Todas son potencias o lineales en +1/Cm, asi que los despejes son cerrados -- no hace falta iterar. + +Procedencia de los coeficientes: sch/lif/results/lif_knowledge_base.md +Medidos con .tran 1n sobre tb_charac_isrc.spice (entrada de corriente, sin M6). +""" +from __future__ import annotations + +# Solo stdlib, y ni siquiera math aqui: son potencias con ** y divisiones. +# El paquete no arrastra numpy a proposito -- son ~15 pow() por diseño, y +# importar numpy costaria ~100 ms para un calculo de 50 us. + +# --- constantes del proceso / celda --------------------------------------- +VDD = 3.3 # V +IEX_REF = 100.0 # nA, corriente a la que se ajusto la ley de frecuencia + +# --- limites de validez ---------------------------------------------------- +# Medidos, no supuestos. Ver "Limites de validez" en la knowledge base. +W_MIN, W_MAX = 0.22, 3.5 # um. Sobre 3.5 la membrana sale del riel. +L_MIN, L_MAX = 20.0, 50.0 # um. L=60 no converge. +L_PRECISE_MIN = 25.0 # bajo esto el error de f sube de ~1% a 5-7% +CM_FLOOR = 50.0 # fF. Bajo esto la ley de Vth diverge. +F_MAX = 4500.0 # kHz. El reset no completa por debajo de ~215 ns. +IEX_VERIFIED_MIN = 5.0 # nA. Verificado sin degradacion; no hay piso real. + + +# --- frecuencia ------------------------------------------------------------ +def freq_at_iex_ref(W: float, L: float) -> float: + """f [kHz] a Iex = 100 nA. RMS 2.03% sobre 69 puntos.""" + return 24837.0 * W ** -1.076 * L ** -0.940 + + +def gain(W: float, L: float) -> float: + """k [kHz/nA], pendiente de f = k*Iex + f0. RMS 2.18%, |max| 4.9%. + + Cm NO interviene: anadirlo al ajuste lo empeora. + + Ajustada sobre las 9 pendientes medidas en Iex 25-400 nA. + + SESGO CONOCIDO: k tiene curvatura -- la pendiente cae al subir la corriente + (13.33 -> 10.47 kHz/nA dentro de una misma serie). En el extremo bajo del + rango la ganancia real es ~11.6% MAYOR que esta ley: medido a 5-10 nA para + W=0.5/L=41 da k=16.41 frente a 14.51 de la formula. + Para f() esto importa poco porque el anclaje proporcional lo compensa + (error ~3%); para consumir gain() directamente a corriente muy baja, + contar con ese margen. + """ + return 280.22 * W ** -1.0447 * L ** -0.9923 + + +def freq(W: float, L: float, iex: float) -> float: + """f [kHz] a una corriente dada. Proporcional pura, sin intercepto. + + El f0 de 14-144 kHz que salia de ajustar rectas sobre Iex 25-400 nA era un + ARTEFACTO: un intercepto ajustado lejos del origen absorbe la curvatura de + la zona alta. Midiendo a 5 y 10 nA (W=0.5, L=41, Cm=150f) sale f0 = +0.57 + kHz, o sea cero, y k = 16.41 kHz/nA. + + Extrapolar la recta con intercepto hacia abajo falla feo: + a 5 nA predice 129.2 kHz, medido 82.6 (+56%) + a 10 nA predice 204.7 kHz, medido 164.6 (+24%) + mientras que este anclaje da -3.4% y -3.1%. + + Queda un sesgo conocido: k tiene curvatura y a corriente baja la pendiente + real es ~11% mayor que la de gain(). Ver la nota en gain(). + """ + return freq_at_iex_ref(W, L) * (iex / IEX_REF) + + +def solve_L_for_freq(W: float, f_target: float, iex: float = IEX_REF) -> float: + """L [um] que da f_target con W fija.""" + f_ref = f_target * IEX_REF / iex + return (24837.0 * W ** -1.076 / f_ref) ** (1.0 / 0.940) + + +def solve_W_for_freq(L: float, f_target: float, iex: float = IEX_REF) -> float: + """W [um] que da f_target con L fija.""" + f_ref = f_target * IEX_REF / iex + return (24837.0 * L ** -0.940 / f_ref) ** (1.0 / 1.076) + + +def solve_L_for_gain(W: float, k_target: float) -> float: + """L [um] que da la ganancia k_target con W fija.""" + return (280.22 * W ** -1.0447 / k_target) ** (1.0 / 0.9923) + + +def solve_W_for_gain(L: float, k_target: float) -> float: + """W [um] que da la ganancia k_target con L fija.""" + return (280.22 * L ** -0.9923 / k_target) ** (1.0 / 1.0447) + + +# --- threshold ------------------------------------------------------------- +def _vth_numerator(W: float, L: float) -> float: + return -16.83 * W + 0.4884 * L + 1.766 * W * L + + +def vth(W: float, L: float, Cm: float) -> float: + """Vth [V] de la membrana. RMS 1.32%. + + Ortogonal a Iex: varia <1.2% con la corriente x16. + """ + return 1.2792 + _vth_numerator(W, L) / Cm + + +def solve_Cm_for_vth(W: float, L: float, vth_target: float) -> float: + """Cm [fF] que da vth_target con (W,L) fijas. + + Lanza ValueError si vth_target <= 1.2792 (la asintota de la ley): por + debajo de ese valor no hay Cm positivo que lo consiga. + """ + denom = vth_target - 1.2792 + if denom <= 0: + raise ValueError( + f"Vth={vth_target:.3f} V esta en o bajo la asintota (1.2792 V); " + "ningun Cm lo alcanza" + ) + return _vth_numerator(W, L) / denom + + +def vth_max_at(W: float, L: float) -> float: + """Vth [V] maximo alcanzable con esa geometria. + + Como Cm >= Cm_min y Vth baja al subir Cm, el techo esta en Cm = Cm_min. + Esta es la razon de que f y Vth esten acoplados: f baja -> W*L grande -> + Cm_min grande -> Vth acotado. + """ + return vth(W, L, Cm_min(W, L)) + + +# --- swing ----------------------------------------------------------------- +def swing(W: float, L: float, Cm: float) -> float: + """Excursion de la membrana [V]. RMS 1.68%. + + Exponentes ~ (1, 1, -1): es W*L/Cm, carga acoplada sobre capacitancia. + """ + return 4.114 * W ** 0.951 * L ** 1.065 * Cm ** -1.006 + + +# --- limite de operacion --------------------------------------------------- +def Cm_min(W: float, L: float) -> float: + """Cm [fF] minimo para que la membrana no salga del riel. + + Conservadora 10-25%: la frontera medida esta en 0.75-0.93 x este valor. + """ + return 8.94 * W ** 1.038 * L ** 0.700 + + +# --- entrada --------------------------------------------------------------- +def c_in(W: float) -> float: + """Capacidad [fF] que la fuente de corriente ve en el nodo de membrana, + aparte de Cm. LOO 0.67% RMS, validacion externa 0.56% RMS. + + NO es potencia sino afin, y por la misma razon que Vth: hay un termino + constante fisico. El 0.945 son las puertas de M1/M2, que cuelgan del nodo + aunque M5 sea minimo; el 0.865*W es la union de drenador de M5. Una + potencia pura tendria que pasar por el origen y falla -21% en W=0.22. + + Solo depende de W. L no entra (4 pares de L medidos, <1.5% entre ellos). + + OJO, NO usar para corregir freq(): la ley de frecuencia se ajusto sobre + simulaciones del circuito completo, con este C_in ya dentro. Sumarlo otra + vez lo cuenta dos veces. Esto sirve para el contrato hacia la etapa previa + (es el c_load que esa etapa debe manejar) y como linea base contra la que + medir la parasita de interconexion cuando se extraiga el layout. + + Residuo conocido: C_in tambien depende de la excursion, no solo de W -- + a mas swing, menos C_in. Entra por ahi lo que parecian dependencias de Cm + y de L. Esto se lleva hasta el 15% del valor de C_in en las esquinas de + Cm grande con W pequeña, pero como C_in solo pesa cuando Cm es pequeño, la + desviacion que traslada a la frecuencia queda bajo 0.15% en todo el + espacio que el solver alcanza. Sin modelar a proposito: afinarlo mas seria + afinar una correccion muy por debajo del error de la ley que corrige. + """ + return 0.945 + 0.865 * W + + +# --- salida ---------------------------------------------------------------- +def c_load_max(w_m7m8: float) -> float: + """Carga capacitiva [fF] que la salida maneja con tf <= 5 ns. + + La carga NO afecta la frecuencia: <0.7% con C_load de 0 a 1600 fF. + """ + return 600.0 * w_m7m8 + + +def solve_w_m7m8_for_load(c_load: float) -> float: + """W [um] de M7/M8 para soportar c_load [fF].""" + return max(W_MIN, c_load / 600.0) + + +def i_drive(w_m7m8: float) -> float: + """Corriente de drive [nA] del buffer de salida.""" + return 85.0 * w_m7m8 + + +# --- requisito sobre la fuente --------------------------------------------- +def min_source_impedance(iex: float, tolerance: float = 0.01) -> float: + """Impedancia de salida minima [ohm] que debe tener la fuente de corriente. + + Una ro finita inyecta corriente parasita proporcional a la caida sobre + ella, y el nodo de membrana oscila ~1.9 V respecto a Vdd: + + dI = (Vdd - Vm) / ro -> ro = (Vdd - Vm) / (tol * Iex) + + Medido: a 100 nA con ro=100M la frecuencia se desvia +22.7%, y con 3M el + circuito deja de oscilar. Un espejo simple (1-10 MOhm) NO sirve; hace + falta cascodo o un transistor largo. + """ + delta_v = 1.9 # V, caida tipica entre Vdd y el nodo de membrana + return delta_v / (tolerance * iex * 1e-9) + + +def freq_error_from_source(iex: float, ro: float) -> float: + """Error relativo de frecuencia por impedancia de fuente finita.""" + if ro <= 0: + return float("inf") + return (1.9 / ro) / (iex * 1e-9) + + +# --- ventana de corriente -------------------------------------------------- +def iex_max(W: float, L: float) -> float: + """Iex [nA] maxima antes de que el reset no complete. + + El techo es de FRECUENCIA (~4500 kHz), no de corriente: se midio en tres + configuraciones que morian a 350, 500 y 600 nA pero todas alrededor de + 4400-4600 kHz. + """ + return F_MAX * IEX_REF / freq_at_iex_ref(W, L) + + +def iex_window(W: float, L: float) -> tuple[float, float]: + """Ventana util de corriente [nA]. + + No hay piso real: verificado hasta 5 nA con ganancia y swing constantes. + Lo que se reporto antes como piso era artefacto de ventana de simulacion. + """ + return (IEX_VERIFIED_MIN, iex_max(W, L)) diff --git a/designs/scripts/lif_design/liberty.py b/designs/scripts/lif_design/liberty.py new file mode 100644 index 0000000..0255f0a --- /dev/null +++ b/designs/scripts/lif_design/liberty.py @@ -0,0 +1,60 @@ +"""Stub de Liberty para integrar la celda como macro. + +LibreLane necesita tres vistas de un bloque analogico: el GDS con la +geometria, el LEF con el contorno y los pines, y un `.lib` que declare +que pines tiene y en que direccion van. Este ultimo es un *stub*: no +lleva tiempos ni potencia, solo la declaracion, que es lo que la +herramienta necesita para no tratar la celda como una caja negra sin +conexiones. + +Los nombres y las direcciones salen del mismo sitio que el netlist de +referencia -- `netlist.de_diseño` -- para que un pin no pueda llamarse de +una forma en el LVS y de otra en el flujo de integracion. +""" +from typing import Optional + + +# Los cinco pines del top, con la direccion que ya declara el .PININFO del +# netlist de referencia. B = bidireccional (alimentacion), I = entrada, +# O = salida. +PINES = ( + ("Vdd", "inout", "power"), + ("Vss", "inout", "ground"), + ("Iin", "input", "signal"), + ("spike", "output", "signal"), + ("spike_neg", "output", "signal"), +) + + +def de_diseño(handles, name: str = "lif", ancho: Optional[float] = None, + alto: Optional[float] = None) -> str: + """Devuelve el contenido del `.lib` para la celda ya construida. + + `ancho` y `alto` en um: el area va en el stub porque la herramienta la + usa para estimar antes de leer el LEF. Si no se pasan, se omite. + """ + lineas = [ + 'library (%s) {' % name, + ' technology (cmos);', + ' delay_model : table_lookup;', + ' time_unit : "1ns";', + ' voltage_unit : "1V";', + ' current_unit : "1uA";', + ' capacitive_load_unit (1, pf);', + ' pulling_resistance_unit : "1kohm";', + '', + ' cell (%s) {' % name, + ] + if ancho is not None and alto is not None: + lineas.append(' area : %.4f;' % (float(ancho) * float(alto))) + lineas.append(' is_macro_cell : true;') + for pin, direccion, tipo in PINES: + lineas.append(' pin (%s) {' % pin) + lineas.append(' direction : %s;' % direccion) + if tipo == "power": + lineas.append(' pg_type : primary_power;') + elif tipo == "ground": + lineas.append(' pg_type : primary_ground;') + lineas.append(' }') + lineas += [' }', '}', ''] + return "\n".join(lineas) diff --git a/designs/scripts/lif_design/mim.py b/designs/scripts/lif_design/mim.py new file mode 100644 index 0000000..dfa224f --- /dev/null +++ b/designs/scripts/lif_design/mim.py @@ -0,0 +1,108 @@ +"""Cuanta capacidad da un MIM de gf180, y que lado hace falta para una dada. + +Esto NO son leyes ajustadas por nosotros como las de laws.py: son los +parametros del modelo del PDK, copiados de + + /foss/pdks/gf180mcuD/libs.tech/ngspice/sm141064.ngspice + +donde cada subcircuito cap_mim_* define + + c_c0 = c_cox * area + c_capsw * perimetro + +El termino de perimetro no es un detalle. En un cap de 5 um de lado aporta +el 21% del total con el modelo de 1.0 fF/um2, asi que multiplicar area por +la densidad del nombre se queda corto justo en el tamaño que usamos. + +Que opcion corre la fabrica es una decision de proceso, no de dibujo: el +layout del MIM es identico en las tres. Lo unico que cambia es cuanto vale, +y hay que elegirla de forma coherente en tres sitios -- el modelo de +simulacion, el mim_option del DRC (A o B) y el par de metales. +""" +from __future__ import annotations + +# c_cox [fF/um2], c_capsw [fF/um] +DENSIDADES = { + "1f0": (0.987, 0.330), + "2f0": (1.990, 0.238), +} + +def par_metales(pdk) -> str: + """La pareja de metales del MIM, como la nombra el PDK: "m2m3", "m4m5"... + + No se escribe: gf180 trae un subcircuito por pareja -- cap_mim_2f0_m2m3, + _m3m4, _m4m5, _m5m6 -- y cual toca depende de donde el PDK ponga capmet. + Los coeficientes son IDENTICOS en todas (c_cox 1.99e-3, c_capsw 2.383e-10): + el sandwich es el mismo nitruro y solo cambia a que altura se inserta, asi + que la capacidad no depende de la pareja. El nombre si, y con el el LVS: + declarar m2m3 una celda construida en met4/met5 da el valor correcto y el + dispositivo equivocado. + """ + bot = pdk.layer_to_glayer(pdk.get_grule("capmet")["capmetbottom"]) + top = pdk.layer_to_glayer(pdk.get_grule("capmet")["capmettop"]) + return "m%sm%s" % (bot[3:], top[3:]) + + +def drc_option(pdk) -> str: + """A o B, segun donde ponga el PDK la placa inferior.""" + bot = pdk.layer_to_glayer(pdk.get_grule("capmet")["capmetbottom"]) + return "A" if bot == "met2" else "B" + +# Decidido por dos criterios, en este orden: +# +# 1. Dejar libres las capas altas para el ruteo entre neuronas cuando la +# celda se replique en una red. Eso descarta la opcion B, que pone el MIM +# entre metal4/metal5 (o metal5/metal6), y fija la A: metal2 / FuseTop / +# metal3, lo mas abajo posible. Coincide con lo que genera glayout. +# 2. A igualdad de lo anterior, minimizar el area del condensador. Eso elige +# la receta mas densa de las tres, 2.0 fF/um2. +# +# OJO: la receta es una decision de TODO el chip, no de esta celda. El PDK +# ofrece las tres pero un proceso solo puede usar una, y hace falta una +# mascara extra (L92). El integrador del equipo ya instancia cap_mim_2f0fF, +# pero el comentario de su notebook del LIF solo cuadra con 1f0, asi que esto +# hay que confirmarlo con el equipo antes de cerrar. +POR_DEFECTO = "2f0" + + +def capacidad(lado: float, mim: str = POR_DEFECTO, n: int = 1) -> float: + """Capacidad [fF] de n MIM cuadrados de `lado` um en paralelo.""" + cox, capsw = DENSIDADES[mim] + return n * (cox * lado * lado + capsw * 4.0 * lado) + + +def lado_para(Cm: float, mim: str = POR_DEFECTO, n: int = 1) -> float: + """Lado [um] de cada uno de n MIM cuadrados que sumen Cm [fF]. + + Invierte cox*s^2 + capsw*4s = Cm/n, que es una cuadratica con una sola + raiz positiva. + """ + if Cm <= 0 or n < 1: + raise ValueError("Cm debe ser positivo y n al menos 1") + cox, capsw = DENSIDADES[mim] + objetivo = Cm / n + b = 4.0 * capsw + return (-b + (b * b + 4.0 * cox * objetivo) ** 0.5) / (2.0 * cox) + + +def modelo(pdk, mim: str = POR_DEFECTO) -> str: + """Nombre del subcircuito del PDK que hay que usar al SIMULAR. + + Lleva la pareja de metales porque el PDK trae uno por cada una. Para el + LVS no sirve: ver modelo_lvs. + """ + return "cap_mim_%s_%s_noshield" % (mim, par_metales(pdk)) + + +def modelo_lvs(mim: str = POR_DEFECTO) -> str: + """Nombre del dispositivo tal como lo EXTRAE el deck de LVS. + + No es el mismo que el de simulacion, y confundirlos cuesta un LVS que + falla con "layout device not in schematic" sin que nada este mal en el + layout. El deck declara + + extract_devices(capacitor('cap_mim_2f0fF', 2.0e-15, MIMCap), ...) + + sin sufijo de metales: la extraccion no distingue la pareja, solo la + densidad. Asi que este nombre es el mismo en opcion A y en B. + """ + return "cap_mim_%sfF" % mim diff --git a/designs/scripts/lif_design/netcheck.py b/designs/scripts/lif_design/netcheck.py new file mode 100644 index 0000000..a13058e --- /dev/null +++ b/designs/scripts/lif_design/netcheck.py @@ -0,0 +1,133 @@ +"""Which net is each probe point on? Run under `klayout -b -r`. + + klayout -b -r netcheck.py -rd gds=cell.gds -rd probes=probes.json + +probes.json maps a name to [layer, datatype, x, y]. Probes that come out with +the same net id are electrically one node; the report groups them. + +Why not an extractor. This was written when our stack had the MIM at +met2/met3 while magic's gf180 techfile puts it at metal4/metal5, so `ext2spice` +could not see the capacitors and reported plates on nets that made no geometric +sense. On MIM option B the two agree again, but this stays: it is fast, needs +no magic, and answers the question that matters here -- what is welded to what. +Devices and their parameters are the LVS's job. +KLayout's LayoutToNetlist is honest about geometry but prunes nets that hold +no device or pin, which is most of a supply grid. So this walks the metal +itself: merge each layer, then let every via weld the shapes it lands on. +""" +import json + +import pya + +# metal, the via above it, metal, ... The order is what makes a via adjacent +# to the two layers it connects. +STACK = [("met1", 34, 0), ("via1", 35, 0), ("met2", 36, 0), ("via2", 38, 0), + ("met3", 42, 0), ("via3", 40, 0), ("met4", 46, 0), ("via4", 41, 0), + ("met5", 81, 0)] +METAL = STACK[0::2] +VIA = STACK[1::2] + +ly = pya.Layout() +ly.read(gds) # noqa: F821 +top = ly.top_cell() + + +def polys(num, dt): + return list(pya.Region(top.begin_shapes_rec(ly.layer(num, dt))) + .merged().each()) + + +shapes = {name: polys(num, dt) for name, num, dt in STACK} + +# The MIM top plate is FuseTop, and the via that lands on it contacts *that*, +# not the bottom plate underneath. Treating capmet as invisible welds the two +# plates together and reports every capacitor as a dead short. Split that via +# into the part over FuseTop and the rest, and give capmet its own nodes. +# +# WHICH via depends on where the PDK puts the MIM: via2 with the plates on +# met2/met3 (option A), via4 with them on met4/met5 (option B). gf180 offers +# both and they are exclusive at the process level, so this looks at the +# layout instead of assuming: the via layer that actually overlaps FuseTop is +# the one to split. Splitting the wrong one leaves the plates welded, and the +# real connections -- which live on the metals the split via reaches -- come +# out invisible. +CAPMET = ("capmet", 75, 0) +shapes[CAPMET[0]] = polys(CAPMET[1], CAPMET[2]) +_capmet = pya.Region([p for p in shapes[CAPMET[0]]]) +_cap_via = next((n for n, _, _ in VIA + if pya.Region([p for p in shapes[n]]).interacting(_capmet).count()), + "via2") +_v = pya.Region([p for p in shapes[_cap_via]]) +shapes[_cap_via] = list(_v.not_(_capmet).each()) +shapes[_cap_via + "_cap"] = list(_v.and_(_capmet).each()) + +# every merged metal polygon is one node; vias only join them +CONDUCTOR = [m[0] for m in METAL] + [CAPMET[0]] +nodes = [(name, i) for name in CONDUCTOR for i in range(len(shapes[name]))] +parent = {n: n for n in nodes} + + +def find(a): + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a + + +def union(a, b): + ra, rb = find(a), find(b) + if ra != rb: + parent[ra] = rb + + +def hits(poly, layer): + """Indices of merged shapes on `layer` that this polygon overlaps.""" + box = poly.bbox() + out = [] + for i, other in enumerate(shapes[layer]): + if box.overlaps(other.bbox()) or box.touches(other.bbox()): + if not pya.Region(poly).and_(pya.Region(other)).is_empty(): + out.append(i) + return out + + +welds = [(vname, METAL[k][0], METAL[k + 1][0]) + for k, (vname, _, _) in enumerate(VIA)] +# La via sobre el FuseTop une la placa superior con el metal de ARRIBA, no con +# el de abajo: ese es el sandwich. Cual sea ese metal depende de la opcion, y +# sale de la misma pila que la via detectada. +_k = [n for n, _, _ in VIA].index(_cap_via) +welds.append((_cap_via + "_cap", CAPMET[0], METAL[_k + 1][0])) + +for vname, below, above in welds: + for via in shapes[vname]: + joined = ([(below, i) for i in hits(via, below)] + + [(above, i) for i in hits(via, above)]) + for other in joined[1:]: + union(joined[0], other) + +out = {} +by_num = {(num, dt): name for name, num, dt in list(METAL) + [CAPMET]} +for name, (num, dt, x, y) in json.load(open(probes)).items(): # noqa: F821 + layer = by_num.get((num, dt)) + pt = pya.Point(int(round(x / ly.dbu)), int(round(y / ly.dbu))) + idx = next((i for i, p in enumerate(shapes.get(layer, [])) + if p.inside(pt)), None) + if layer is None: + estado, out[name] = "capa no es de metal", None + elif idx is None: + # missing the metal and standing on floating metal are different + # findings; do not report them as the same thing + estado, out[name] = "PUNTO FUERA DE LA CAPA", None + else: + root = find((layer, idx)) + out[name] = "%s#%d" % root + estado = out[name] + print("%-22s %-6s (%8.3f,%8.3f) -> %s" % (name, layer, x, y, estado)) + +groups = {} +for name, net in out.items(): + groups.setdefault(net, []).append(name) +print("\n--- redes ---") +for net, names in sorted(groups.items(), key=lambda kv: str(kv[0])): + print(" %-14s %s" % (net or "SIN RED", " ".join(sorted(names)))) diff --git a/designs/scripts/lif_design/netlist.py b/designs/scripts/lif_design/netlist.py new file mode 100644 index 0000000..52f314d --- /dev/null +++ b/designs/scripts/lif_design/netlist.py @@ -0,0 +1,74 @@ +"""El esquematico de referencia, emitido desde el mismo diseño que el layout. + +En un flujo a mano el LVS es un DESCUBRIMIENTO: se dibuja el esquematico por +un lado, el layout por otro, y la comparacion revela si divergieron. Aqui es +una COMPROBACION: las dos salidas vienen de la misma NeuronDesign, asi que un +fallo solo puede significar un bug del generador. La clase de fallo "alguien +tecleo distinto en dos sitios" no existe. + +La topologia es fija -- tres inversores, el interruptor M5 y el banco de MIM -- +y lo unico que el solver decide son cuatro dimensiones y cuantos condensadores. + +Los nombres de nodo son los que `_pin_labels` marca en el GDS: sin esa +correspondencia el extractor nombra las redes por su cuenta y el LVS compara +etiquetas que no existen. +""" +from __future__ import annotations + +from . import mim as mim_pdk + +# Nodos internos. `integration` es la membrana y va al pin Iin -- la fuente de +# corriente entra ahi, asi que el nodo del integrador ES el puerto de entrada. +_RESET = "spike/reset" + + +def de_diseño(design, handles, name: str = "lif") -> str: + """Netlist SPICE del diseño, para comparar contra el GDS. + + Todas las cifras salen de `handles`, no de `design`: son las del generador, + ya subidas a la rejilla del GDS, y el modelo del MIM depende de donde el + PDK ponga capmet. `design` se mantiene en la firma porque es lo que + identifica de que diseño es este netlist. + """ + del design # la topologia es fija; las dimensiones vienen de handles + # Lo DIBUJADO, no lo pedido: from_design sube cada dimension a la rejilla + # del GDS, y el comparador ve el ancho que hay en el layout. + d = handles["dims"] + w_inv, l_inv = d["W_inv"], d["L_inv"] # los inversores van al minimo + n_caps = len(handles["caps"]) + lado = float(handles["cap_lado"]) + + def fet(nombre, d, g, s, b, tipo, w, l): + return ("M%s %s %s %s %s %s L=%gu W=%gu nf=1 m=1" + % (nombre, d, g, s, b, tipo, l, w)) + + lineas = [ + ".subckt %s Vdd Vss Iin spike spike_neg" % name, + "*.PININFO Vdd:B Vss:B Iin:I spike:O spike_neg:O", + # inversor 0: la membrana lo excita, su salida es spike_neg + fet("1", "spike_neg", "Iin", "Vdd", "Vdd", "pfet_03v3", w_inv, l_inv), + fet("2", "spike_neg", "Iin", "Vss", "Vss", "nfet_03v3", w_inv, l_inv), + # inversor 1: cierra el lazo de reset sobre la puerta de M5 + fet("3", _RESET, "spike_neg", "Vdd", "Vdd", "pfet_03v3", w_inv, l_inv), + fet("4", _RESET, "spike_neg", "Vss", "Vss", "nfet_03v3", w_inv, l_inv), + # inversor 2: el bufer de salida, el unico que el solver dimensiona + fet("7", "spike", "spike_neg", "Vdd", "Vdd", "pfet_03v3", + d["W_M7M8"], l_inv), + fet("8", "spike", "spike_neg", "Vss", "Vss", "nfet_03v3", + d["W_M7M8"], l_inv), + # M5, el interruptor que descarga la membrana + fet("5", "Iin", _RESET, "Vss", "Vss", "nfet_03v3", + d["W_M5"], d["L_M5"]), + # el banco. m=n en vez de n instancias: el comparador combina + # dispositivos en paralelo, asi que las dos formas casan con la + # extraccion, y una sola linea dice lo que hay. + # + # W/L/M, no c_width/c_length: el lector del deck de gf180 + # (custom_classes.lvs) traduce W*L*M -> area y (W+L)*M*2 -> perimetro, + # que es con lo que compara. Cualquier otro nombre lo ignora en + # silencio y el condensador entra con area cero. + "XC1 Iin Vss %s W=%gu L=%gu M=%d" + % (mim_pdk.modelo_lvs(), lado, lado, n_caps), + ".ends", + ] + return "\n".join(lineas) + "\n" diff --git a/designs/scripts/lif_design/place.py b/designs/scripts/lif_design/place.py new file mode 100644 index 0000000..a473f41 --- /dev/null +++ b/designs/scripts/lif_design/place.py @@ -0,0 +1,678 @@ +"""Placement: where blocks go, in terms the PDK can justify. + +Every distance this module produces is either read from a design rule or +derived from the routes that have to fit -- never a tuning factor. That is the +whole point: `9 * util_max_metal_seperation()` places a transistor just as +well as the right number does, right up until someone changes the device size +and the layout silently stops being minimal, or silently stops being legal. + +Three facts about glayout make this possible without generating and measuring: + + * `evaluate_bbox(comp)` gives the size of a generated cell, + * the `well_N/S/E/W` ports give the well outline *and its layer*, and + * the component's own polygons say how far up the stack it reaches. + +On a gf180 FET the first two coincide -- the cell bounding box is the well -- +so the clearance between two adjacent blocks is a well-to-well rule, not a +metal one. That distinction is not academic: on the LIF neuron the binding +constraint is NW.2b (nwell to nwell, 1.4 um) between neighbouring pfets, while +the metal separation is 0.3 um. Spacing blocks by the metal rule produces a +layout that looks generous and still fails DRC. + +Routing does *not* enter the gap. A wire does not need a corridor between two +blocks; it needs a layer that is free above the blocks it crosses. gf180 FETs +stop at met2, so anything on met3 flies straight over them and the gap never +hears about it. That is why the block dimension dominates: gaps are a +well-clearance question and nothing else. + +What routing does decide is whether a net is routable at all. A net stuck on a +layer that one of the blocks under it already occupies has to climb or detour +-- and a detour costs row height, not gap. `clearances()` reports that, so the +choice is visible instead of being discovered as a short. + +Typical use: + + p = Cell.from_component("pfet", pmos(pdk, ...), pdk) + n = Cell.from_component("nfet", nmos(pdk, ...), pdk) + inv = pair(p, n, pdk) + row = plan_row([inv.as_cell()] * 3, nets, pdk) + for c in clearances(nets, [inv.as_cell()] * 3, pdk): + print(c) + +Nothing here writes layout. It returns coordinates; the generator applies them. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional, Sequence + +# glayer name for each well layer a cell might carry. Kept as a mapping rather +# than a lookup on the pdk because a cell reports the *layer* of its well port +# and the rules are keyed by glayer name. +_WELL_GLAYERS = ("nwell", "pwell", "dnwell") + +# Routing layers, bottom to top. A net can fly over a block when its layer sits +# strictly above everything the block occupies. +_STACK = ("met1", "met2", "met3", "met4", "met5") + + +def _level(glayer: Optional[str]) -> int: + """Height of a glayer in the metal stack; -1 for anything below met1.""" + return _STACK.index(glayer) if glayer in _STACK else -1 + + +# -------------------------------------------------------------------------- +# what a placeable thing is +# -------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Cell: + """A generated block: its size, and which well its outline is. + + `well` is the glayer name ("nwell", "pwell") or None for a block with no + well of its own -- a mimcap, say. Blocks without a well have no well + clearance to anything, so their spacing comes from routing alone. + """ + name: str + width: float + height: float + wells: tuple = () # (glayer, inset from this cell's side edge) + layers: frozenset = frozenset() # every glayer the block's geometry uses + insets: tuple = () # (glayer, how far inside the outline it starts) + + def inset(self, glayer: str) -> float: + """How far inside this block's outline `glayer` begins.""" + return next((v for g, v in self.insets if g == glayer), 0.0) + + @property + def well(self) -> Optional[str]: + """The outermost well, for reporting.""" + if not self.wells: + return None + return min(self.wells, key=lambda w: w[1])[0] + + @property + def top_layer(self) -> Optional[str]: + """Highest routing layer the block reaches; None if it has no metal.""" + metals = [l for l in self.layers if l in _STACK] + return max(metals, key=_level) if metals else None + + @classmethod + def from_component(cls, name: str, comp, pdk) -> "Cell": + """Read size, wells and occupied layers off a generated Component.""" + from glayout.util.comp_utils import evaluate_bbox + + w, h = evaluate_bbox(comp) + found = _well_of(comp, pdk) + return cls(name=name, width=float(w), height=float(h), + wells=((found, 0.0),) if found else (), + layers=_layers_of(comp, pdk), + insets=_insets_of(comp, pdk)) + + +def _polygons(comp): + """Every polygon in a Component, on either glayout backend. + + gdsfactory exposes get_polygons(); the gdstk shim keeps them on the + underlying cell. Both are tried rather than picking one, so a cell built + under either backend reads the same. + """ + getter = getattr(comp, "get_polygons", None) + if callable(getter): + got = getter() + if got: + return got + cell = getattr(comp, "_cell", None) + if cell is not None: + return cell.get_polygons() + return [] + + +def _layers_of(comp, pdk) -> frozenset: + """Every glayer the block's own geometry occupies. + + The whole set, not just the highest: two blocks that share a layer owe + each other that layer's separation even when neither has a well. The + highest one alone decides what can fly over. + """ + out = set() + for poly in _polygons(comp): + try: + out.add(pdk.layer_to_glayer((poly.layer, poly.datatype))) + except Exception: + continue + return frozenset(out - {None}) + + +def _insets_of(comp, pdk) -> tuple: + """How far inside the block's own outline each glayer's geometry starts. + + A mimcap's CAP_MK marker is wider than its met2 plate, so the block's + outline overstates where its metal actually is. Spacing that ignores this + asks for a bigger gap than the rule wants -- and, where a rule is measured + from the metal rather than the outline, can ask for too small a one. + Taken as the smallest of the four sides, which is the safe direction. + """ + bb = comp.bbox + x0, y0, x1, y1 = float(bb[0][0]), float(bb[0][1]), float(bb[1][0]), float(bb[1][1]) + per = {} + for poly in _polygons(comp): + try: + glayer = pdk.layer_to_glayer((poly.layer, poly.datatype)) + except Exception: + continue + if glayer is None: + continue + try: + pb = poly.bounding_box() + px0, py0, px1, py1 = pb[0][0], pb[0][1], pb[1][0], pb[1][1] + except Exception: + pts = getattr(poly, "points", None) + if pts is None: + continue + xs = [float(q[0]) for q in pts] + ys = [float(q[1]) for q in pts] + px0, py0, px1, py1 = min(xs), min(ys), max(xs), max(ys) + near = min(px0 - x0, x1 - px1, py0 - y0, y1 - py1) + per[glayer] = min(per.get(glayer, near), near) + return tuple(sorted((g, max(0.0, v)) for g, v in per.items())) + + +def _well_of(comp, pdk) -> Optional[str]: + """The glayer name of the cell's well, from its well_* ports.""" + layers = {tuple(p.layer) for nm, p in comp.ports.items() + if nm.startswith("well_")} + if not layers: + return None + for glayer in _WELL_GLAYERS: + try: + if tuple(pdk.get_glayer(glayer)) in layers: + return glayer + except (KeyError, ValueError): + continue + return None + + +# -------------------------------------------------------------------------- +# the two distances +# -------------------------------------------------------------------------- + +def _grule(pdk, *layers) -> dict: + """A rule between glayers, or {} when the PDK does not define one. + + Missing rules are normal, not exceptional: a marker layer has no spacing + to itself, and glayout raises NotImplementedError rather than returning + empty. Callers want "no constraint", so that is what they get. + """ + try: + return pdk.get_grule(*layers) or {} + except Exception: + return {} + + +def well_clearance(pdk, a: Cell, b: Cell) -> float: + """Space the wells demand between two blocks placed side by side. + + Every well of one block is checked against every well of the other, and + each pair's requirement is reduced by how far those wells sit inside their + own block. A stacked pair is the case that needs this: its pfet is + narrower than its nfet, so the nwell starts further in and two neighbouring + pairs can stand closer than the raw nwell rule suggests. Collapsing a + stack to a single well throws that away and, when the two well rules + differ, gets the answer wrong in the unsafe direction. + """ + worst = 0.0 + for well_a, inset_a in a.wells: + for well_b, inset_b in b.wells: + rule = _grule(pdk, well_a, well_b) + need = float(rule.get("min_separation", 0.0)) - inset_a - inset_b + worst = max(worst, need) + return worst + + +def pitch(pdk, glayer: str = "met2") -> float: + """Centre-to-centre spacing of two wires on `glayer`. + + Not used for gaps -- gaps are a well question. This is for sizing a track + budget when a net really does have to run alongside others on one layer. + """ + rule = pdk.get_grule(glayer) + return float(rule["min_width"]) + float(rule["min_separation"]) + + +MIM_BOTTOM_TO_MET2 = 1.2 # gf180 MIM.1, option A + + +def shared_clearance(pdk, a: Cell, b: Cell) -> tuple[float, Optional[str]]: + """Widest separation demanded by a layer both blocks occupy. + + Two blocks facing each other on the same layer owe that layer's spacing + even when neither has a well -- a mimcap has no well at all and still + cannot sit flush against a transistor's met2. + """ + worst, which = 0.0, None + for glayer in a.layers & b.layers: + sep = float(_grule(pdk, glayer).get("min_separation", 0.0)) + # Descontando lo que cada capa esta metida en su contorno, igual que + # hace MIM.1 mas abajo. Sin esto la separacion se pide entre bordes de + # bloque y no entre el metal de verdad: el capmet de un mimcap empieza + # 1.8 um dentro, asi que exigir 1.2 entre contornos dejaba 4.8 um entre + # FuseTops donde la regla pide 1.2 -- 2.4 um regalados por hueco. + need = sep - a.inset(glayer) - b.inset(glayer) + if need > worst: + worst, which = need, glayer + + # MIM.1: a MIM bottom plate owes 1.2um to any other met2, whether that is + # another MIM or plain routing metal -- four times met2's own separation. + # The gf180 deck only checks this when the run passes mim_option, and it + # defaults to "Nan", so a clean DRC report is no evidence either way. + for x, y in ((a, b), (b, a)): + if "capmet" in x.layers and "met2" in y.layers: + need = MIM_BOTTOM_TO_MET2 - x.inset("met2") - y.inset("met2") + if need > worst: + worst, which = need, "MIM.1" + return worst, which + + +def gap_between(pdk, a: Cell, b: Cell, minimum: float = 0.0) -> float: + """The gap two neighbouring blocks need. + + The wells and the layers they share set this; routes do not, because they + cross above the blocks rather than through the space between them. + `minimum` is an explicit floor for the caller who wants one -- a seal + ring, a keep-out -- not a fudge factor. + """ + return max(well_clearance(pdk, a, b), shared_clearance(pdk, a, b)[0], minimum) + + +# -------------------------------------------------------------------------- +# a complementary pair, stacked +# -------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Stack: + """Two blocks one above the other, centred on the same vertical axis. + + Centre alignment is not a stylistic choice here -- it is the invariant the + LIF inverters already hold (measured: 0.000 um offset across all three), + and holding it keeps the gate and drain links vertical, which is what lets + the gap be one channel wide instead of a detour. + """ + name: str + top: Cell + bottom: Cell + gap: float + + @property + def width(self) -> float: + return max(self.top.width, self.bottom.width) + + @property + def height(self) -> float: + return self.top.height + self.gap + self.bottom.height + + def as_cell(self) -> Cell: + """Treat the stack as one block for row planning. + + Both members' wells are carried through, each with the inset that its + own width gives it against the stack's side. Nothing is collapsed: + which well ends up binding is left to the spacing rules. + """ + wells = [] + for member in (self.top, self.bottom): + inset = (self.width - member.width) / 2 + wells.extend((name, base + inset) for name, base in member.wells) + return Cell(self.name, self.width, self.height, tuple(wells), + self.top.layers | self.bottom.layers) + + def offsets(self) -> dict[str, tuple[float, float]]: + """Centre of each member relative to the stack centre.""" + half = self.height / 2 + return { + self.top.name: (0.0, half - self.top.height / 2), + self.bottom.name: (0.0, self.bottom.height / 2 - half), + } + + +def pair(top: Cell, bottom: Cell, pdk, minimum: float = 0.0, + name: Optional[str] = None) -> Stack: + """Stack two blocks, centre-aligned, with the gap the wells require. + + On gf180 nwell and pwell may abut, so a complementary pair has no + well-driven gap at all and `minimum` is what keeps them apart -- measured + on the LIF inverter, 0.950 um is enough for the gate and drain links to + turn. + """ + return Stack(name=name or f"{top.name}_{bottom.name}", + top=top, bottom=bottom, + gap=gap_between(pdk, top, bottom, minimum)) + + +# -------------------------------------------------------------------------- +# power rails +# -------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Rails: + """The VDD and VSS bands that bound a row. + + Rails are declared up front rather than routed afterwards because they set + the row height and the orientation of everything in it. A placement that + ignores them has to be redone once they arrive -- which is how the LIF + neuron ended up with VDD and VSS exposed as ports and connected to + nothing. + """ + glayer: str = "met2" + width: float = 0.0 # rail conductor width, um + clearance: float = 0.0 # rail to nearest block, um + channel: float = 0.0 # routing reserved between the blocks and the rail + + @classmethod + def minimum(cls, pdk, glayer: str = "met2", width: Optional[float] = None, + tracks: int = 0, track_glayer: str = "met2", + clearance: Optional[float] = None) -> "Rails": + """Rails at minimum width, with room for `tracks` wires beneath them. + + `tracks` is what turns a row of isolated cells into a row that can be + wired up. A block's output sits at the top of the device and the next + block's input at the bottom -- 2.89 um apart on a gf180 inverter -- so + a link between stages has to change height somewhere, and with the + rails pushed up against the devices there is nowhere to do it. Left at + zero the row still builds; it just cannot be chained, and the attempt + shorts the link into whichever rail it runs into. + + `clearance` sobreescribe la separacion. La del riel se calcula con la + regla de SU capa, y basta mientras lo unico que se le acerque sea + metal de esa capa. No basta cuando algo aterriza en el riel viniendo + de arriba: la pila deja un pad en cada capa que atraviesa, y uno de + esos puede caer bajo una regla mas dura que la del riel. + """ + rule = pdk.get_grule(glayer) + return cls(glayer=glayer, + width=float(width if width is not None else rule["min_width"]), + clearance=float(rule["min_separation"] + if clearance is None else clearance), + channel=pitch(pdk, track_glayer) * max(0, tracks)) + + @classmethod + def above(cls, pdk, blocks, width: Optional[float] = None, + tracks: int = 0, clearance: Optional[float] = None) -> "Rails": + """Rails on the first layer every block in the row leaves free. + + A rail on a layer some block already uses has to weave around its + contents; one layer up it crosses them without touching. Same rule + that decides whether a net can fly over -- rails are just nets that + every cell in the row connects to. + + It takes the whole row, not one block, because the block that decides + the layer need not be the one that decides the height. In a LIF cell + the inverter stack is the tallest thing by a wide margin while the + mimcap is the only one reaching met3: sizing the rails off the tallest + block alone puts them on met3, straight through the cap. + + Un bloque que llega a la CIMA de la pila no participa: la regla + consiste en subir un piso y ahi no queda ninguno. Ese bloque no puede + tener rieles por encima ni aunque se quiera, asi que dejarlo decidir + solo sirve para no devolver nada. Es el caso del mimcap con el MIM en + met4/met5 -- su placa superior es met5 y antes esto levantaba. + + Excluirlo traslada una responsabilidad al llamante: los rieles no + pueden cruzar ese bloque, y quien coloca la fila tiene que ponerlo + donde no lo hagan. En la celda LIF se cumple porque los rieles corren + por los extremos y el banco esta dentro. + """ + if isinstance(blocks, Cell): + blocks = [blocks] + blocks = list(blocks) + cima = len(_STACK) - 1 + elegibles = [b for b in blocks if _level(b.top_layer) < cima] + if not elegibles: + raise ValueError( + "every block in the row reaches " + f"{_STACK[cima]}, the top of the stack -- no free layer left " + "for rails") + highest = max(elegibles, key=lambda b: _level(b.top_layer)) + block = highest + level = _level(highest.top_layer) + 1 + # The channel sits below the rail, so it belongs to the layer the + # links will actually run on -- the blocks' own top layer, which is + # free between them. + return cls.minimum(pdk, _STACK[level], width, tracks=tracks, + track_glayer=block.top_layer or _STACK[0], + clearance=clearance) + + @property + def band(self) -> float: + """Vertical space one rail costs the row, channel included.""" + return self.channel + self.width + self.clearance + + +# -------------------------------------------------------------------------- +# a row of blocks +# -------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Net: + """A connection between block ports, written as `block.port`.""" + name: str + endpoints: tuple[str, ...] + + def blocks(self) -> set[str]: + return {e.split(".", 1)[0] for e in self.endpoints} + + +@dataclass(frozen=True) +class Clearance: + """Whether a net can cross the blocks between its endpoints. + + `layer` is the lowest one that clears them all; None means every routing + layer is occupied somewhere along the way and the net has to go around. + """ + net: str + spans: tuple[str, ...] # blocks it passes over + blocked_by: tuple[str, ...] # blocks reaching the top of the stack + layer: Optional[str] + + def __str__(self) -> str: + if not self.spans: + return f"{self.net}: adjacent, nothing to cross" + over = ", ".join(self.spans) + if self.layer: + return f"{self.net}: flies over {over} on {self.layer}" + return (f"{self.net}: no free layer over {over} " + f"(blocked by {', '.join(self.blocked_by)}) -- must detour") + + +def clearances(nets: Sequence[Net], blocks: Sequence[Cell], pdk=None + ) -> list[Clearance]: + """For each net, the lowest layer that clears the blocks it spans. + + Blocks keep the order given; a net spans everything strictly between its + leftmost and rightmost endpoint. A net whose endpoints are neighbours + crosses nothing and is always routable. + """ + order = [b.name for b in blocks] + index = {name: i for i, name in enumerate(order)} + by_name = {b.name: b for b in blocks} + out = [] + + for net in nets: + touched = sorted((index[b] for b in net.blocks() if b in index)) + if len(touched) < 2: + out.append(Clearance(net.name, (), (), _STACK[0])) + continue + spanned = [order[i] for i in range(touched[0] + 1, touched[-1])] + if not spanned: + out.append(Clearance(net.name, (), (), _STACK[0])) + continue + highest = max(_level(by_name[n].top_layer) for n in spanned) + layer = _STACK[highest + 1] if highest + 1 < len(_STACK) else None + blocked = tuple(n for n in spanned + if _level(by_name[n].top_layer) == highest) if layer is None else () + out.append(Clearance(net.name, tuple(spanned), blocked, layer)) + return out + + +@dataclass +class RowPlan: + """Coordinates and the reasoning behind them.""" + order: list[str] + x: list[float] = field(default_factory=list) # left edge of each block + gaps: list[float] = field(default_factory=list) # len == len(order) - 1 + binding: list[str] = field(default_factory=list) # what set each gap + width: float = 0.0 + height: float = 0.0 + rails: Optional[Rails] = None + notes: list[str] = field(default_factory=list) + + def report(self) -> str: + out = [f"row {self.width:.3f} x {self.height:.3f} um"] + if self.rails: + out.append(f" rails on {self.rails.glayer}: " + f"{self.rails.width:.3f} um wide, " + f"{self.rails.band:.3f} um per band") + for i, name in enumerate(self.order): + out.append(f" x={self.x[i]:8.3f} {name}") + if i < len(self.gaps): + out.append(f" {'gap':>9} {self.gaps[i]:6.3f} um " + f"(set by {self.binding[i]})") + out.extend(f" note: {n}" for n in self.notes) + return "\n".join(out) + + +@dataclass +class Band: + """One horizontal strip of the cell, and what sits in it. + + Bands are how a cell stops being a single row. Grouping by device type + rather than by function is what makes them worth having: three pfets in + one band abut inside a shared nwell, while three inverters placed as units + pay NW.2b between every pair. It also leaves the strip beside a long + device free for whatever else fits -- on a LIF the capacitors go under + M5, which is otherwise 45% dead area. + """ + name: str + blocks: list + plan: Optional[RowPlan] = None + y: float = 0.0 # centre of the band, filled by plan_bands + + @property + def height(self) -> float: + return max((b.height for b in self.blocks), default=0.0) + + +@dataclass +class Floorplan: + """Bands stacked bottom to top, with the rails outside them.""" + bands: list + width: float = 0.0 + height: float = 0.0 + gaps: list = field(default_factory=list) # between consecutive bands + rails: Optional[Rails] = None + + def report(self) -> str: + out = [f"floorplan {self.width:.3f} x {self.height:.3f} um"] + for i, band in enumerate(self.bands): + out.append(f" y={band.y:8.3f} {band.name:<12} " + f"{band.plan.width:7.3f} x {band.height:6.3f}" + f" [{', '.join(b.name for b in band.blocks)}]") + if i < len(self.gaps): + out.append(f" {'gap':>9} {self.gaps[i]:6.3f} um") + return "\n".join(out) + + +def _band_clearance(pdk, lower: Band, upper: Band) -> float: + """Vertical space between two bands. + + Taken as the worst case over every pair of blocks that could face each + other across the gap. Conservative on purpose: which block of one band + ends up above which of the other depends on x, and the planner does not + model that yet. + """ + worst = 0.0 + for a in lower.blocks: + for b in upper.blocks: + worst = max(worst, well_clearance(pdk, a, b), + shared_clearance(pdk, a, b)[0]) + return worst + + +def plan_bands(bands: Sequence[Band], pdk, rails: Optional[Rails] = None, + nets: Sequence["Net"] = (), + rail_clearance: Optional[float] = None) -> Floorplan: + """Lay out bands bottom to top; each band is planned as its own row. + + The rails bound the whole stack rather than each band, so a tall block in + one band pushes them out for everybody -- same rule as within a row, one + level up. + """ + bands = list(bands) + for band in bands: + band.plan = plan_row(band.blocks, nets, pdk) + + every = [b for band in bands for b in band.blocks] + rails = rails or Rails.above(pdk, every, clearance=rail_clearance) + + gaps = [_band_clearance(pdk, bands[i], bands[i + 1]) + for i in range(len(bands) - 1)] + + y = 0.0 + for i, band in enumerate(bands): + band.y = y + band.height / 2 + y += band.height + if i < len(gaps): + y += gaps[i] + + return Floorplan(bands=bands, + width=max((b.plan.width for b in bands), default=0.0), + height=y + 2 * rails.band, + gaps=gaps, rails=rails) + + +def plan_row(blocks: Sequence[Cell], nets: Sequence[Net], pdk, + rails: Optional[Rails] = None, minimum: float = 0.0) -> RowPlan: + """Place blocks left to right in the order given. + + Gaps come from the wells. Nets are read only to report which of them have + to detour -- they never widen the row, because they cross above it. + """ + order = [b.name for b in blocks] + plan = RowPlan(order=order, rails=rails) + + dupes = {n for n in order if order.count(n) > 1} + if dupes: + plan.notes.append(f"repeated block names, gaps may be misattributed: " + f"{sorted(dupes)}") + + unknown = {e.split('.', 1)[0] for n in nets for e in n.endpoints} - set(order) + if unknown: + plan.notes.append(f"nets reference blocks not in the row: {sorted(unknown)}") + + for i in range(len(blocks) - 1): + by_well = well_clearance(pdk, blocks[i], blocks[i + 1]) + by_layer, layer = shared_clearance(pdk, blocks[i], blocks[i + 1]) + plan.gaps.append(max(by_well, by_layer, minimum)) + if by_well >= max(by_layer, minimum): + plan.binding.append( + f"wells ({blocks[i].well}/{blocks[i + 1].well})" + if by_well > 0 else "nothing -- blocks may abut") + elif by_layer >= minimum: + plan.binding.append(f"shared {layer}") + else: + plan.binding.append("caller minimum") + + for c in clearances(nets, blocks, pdk): + if c.spans and c.layer is None: + plan.notes.append(str(c)) + + x = 0.0 + for i, b in enumerate(blocks): + plan.x.append(x) + x += b.width + if i < len(plan.gaps): + x += plan.gaps[i] + + plan.width = x + tall = max((b.height for b in blocks), default=0.0) + plan.height = tall + (2 * rails.band if rails else 0.0) + return plan diff --git a/designs/scripts/lif_design/preview.py b/designs/scripts/lif_design/preview.py new file mode 100644 index 0000000..809003b --- /dev/null +++ b/designs/scripts/lif_design/preview.py @@ -0,0 +1,151 @@ +"""Render a generated cell to PNG, with its DRC violations marked. + +Numbers say a rule failed; a picture says which side of the device is already +occupied. Working from the report alone cost two full sweeps to learn that the +gate route leaves by the west and nothing else fits there -- one look at the +layout would have said so. + + python preview.py out.png cell.gds[:cell.lyrdb] [more.gds[:more.lyrdb] ...] + +Several inputs are laid out as a contact sheet, which is the useful form when +comparing what one parameter did across a sweep. +""" +from __future__ import annotations + +import io +import re +import sys +from collections import Counter + +import gdstk +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Polygon as MplPoly + +# (layer, datatype) -> colour, label, alpha. Ordered bottom of the stack up, so +# later layers paint over earlier ones the way a cross-section reads. +LAYERS = [ + ((21, 0), "#9e9e4a", "nwell", 0.20), + ((204, 0), "#c9a0dc", "lvpwell", 0.16), + ((12, 0), "#8a6d3b", "dnwell", 0.14), + ((22, 0), "#7ac97a", "comp", 0.45), + ((30, 0), "#cc3333", "poly2", 0.60), + ((31, 0), "#e6b800", "pplus", 0.14), + ((32, 0), "#5b9bd5", "nplus", 0.14), + ((33, 0), "#000000", "contact", 0.85), + ((34, 0), "#3b6fd4", "met1", 0.45), + ((35, 0), "#111111", "via1", 0.85), + ((36, 0), "#d45f3b", "met2", 0.45), + ((38, 0), "#222222", "via2", 0.85), + # El MIM va entre met2 y met3 (opcion A), asi que se pinta AQUI, no al + # final. Pintarlo el ultimo lo ponia por encima de met3, met4 y met5, que + # estan fisicamente mas arriba que el. + ((75, 0), "#c026d3", "fusetop", 0.70), + ((42, 0), "#2e9e57", "met3", 0.55), + ((40, 0), "#333333", "via3", 0.85), + ((46, 0), "#8e44ad", "met4", 0.60), + ((41, 0), "#333333", "via4", 0.85), + ((81, 0), "#e07b39", "met5", 0.60), + # CAP_MK si va al final a proposito: es un marcador, no ocupa sitio en el + # stack, y con alfa muy baja hace de halo sin tapar nada. + ((117, 5), "#7a7a7a", "CAP_MK", 0.16), +] + + +def violations(report: str): + """Centres and per-rule counts from a KLayout lyrdb report. + + Coordinates come as `x1,y1;x2,y2;...` and sometimes as exact fractions, + so the numeric part is taken before any '/'. + """ + points, rules = [], Counter() + try: + text = io.open(report, encoding="utf-8", errors="ignore").read() + except OSError: + return points, rules + for chunk in text.split("")[1:]: + rule = re.search(r"'?([^<']+)'?", chunk) + vals = re.search(r"(.*?)", chunk, re.S) + if not rule: + continue + rules[rule.group(1)] += 1 + if not vals: + continue + nums = re.findall(r"(-?\d+(?:\.\d+)?)(?:/\d+)?", vals.group(1)) + xs = [float(n) for n in nums[0::2]] + ys = [float(n) for n in nums[1::2]] + if xs and ys: + points.append((sum(xs) / len(xs), sum(ys) / len(ys))) + return points, rules + + +def draw(ax, gds: str, report: str | None, title: str | None = None) -> None: + cell = gdstk.read_gds(gds).top_level()[0] + polys = cell.get_polygons() + for key, colour, _name, alpha in LAYERS: + for poly in polys: + if (poly.layer, poly.datatype) == key: + ax.add_patch(MplPoly(poly.points, closed=True, facecolor=colour, + edgecolor="none", alpha=alpha, linewidth=0)) + + # El FuseTop queda tapado por el met3 que lo contacta, y es justo la capa + # que distingue un MIM bueno de uno en corto -- sin ella el condensador es + # un sandwich de metal. Se repasa el contorno por encima de todo para no + # perder ese indicador de un vistazo. + for poly in polys: + if (poly.layer, poly.datatype) == (75, 0): + ax.add_patch(MplPoly(poly.points, closed=True, facecolor="none", + edgecolor="#c026d3", linewidth=1.4, zorder=4)) + + points, rules = violations(report) if report else ([], Counter()) + for cx, cy in points: + ax.plot(cx, cy, "o", ms=10, mfc="none", mec="red", mew=1.6, zorder=5) + + box = cell.bounding_box() + w, h = box[1][0] - box[0][0], box[1][1] - box[0][1] + ax.set_xlim(box[0][0] - 1, box[1][0] + 1) + ax.set_ylim(box[0][1] - 1, box[1][1] + 1) + ax.set_aspect("equal") + ax.grid(alpha=0.15, lw=0.4) + detail = " ".join(f"{k} x{v}" for k, v in rules.most_common(3)) + ax.set_title(f"{title or cell.name}\n{w:.2f} x {h:.2f} um = {w * h:.0f} um2" + f" DRC {sum(rules.values())}\n{detail or 'sin violaciones'}", + fontsize=9) + + +def main(out: str, specs: list[str]) -> None: + cols = min(3, len(specs)) + rows = (len(specs) + cols - 1) // cols + fig, axes = plt.subplots(rows, cols, figsize=(6.5 * cols, 6.0 * rows), + squeeze=False) + flat = [ax for row in axes for ax in row] + + for ax, spec in zip(flat, specs): + gds, _, report = spec.partition(":") + label = gds.rsplit("/", 1)[-1].rsplit(".", 1)[0] + draw(ax, gds, report or None, label) + for ax in flat[len(specs):]: + ax.axis("off") + + handles = [Line2D([0], [0], marker="s", color="none", markersize=10, + markerfacecolor=c, alpha=min(1, a + 0.3), label=n) + for _, c, n, a in LAYERS] + handles.append(Line2D([0], [0], marker="o", color="none", markersize=9, + markerfacecolor="none", markeredgecolor="red", + label="violacion DRC")) + flat[0].legend(handles=handles, loc="upper left", + bbox_to_anchor=(1.01, 1.0) if len(specs) == 1 else (0, -0.08), + ncol=1 if len(specs) == 1 else 8, + fontsize=8, frameon=False) + + plt.tight_layout() + plt.savefig(out, dpi=140, bbox_inches="tight", facecolor="white") + print(f"escrito {out}") + + +if __name__ == "__main__": + if len(sys.argv) < 3: + raise SystemExit(__doc__) + main(sys.argv[1], sys.argv[2:]) diff --git a/designs/scripts/lif_design/solver.py b/designs/scripts/lif_design/solver.py new file mode 100644 index 0000000..09537c1 --- /dev/null +++ b/designs/scripts/lif_design/solver.py @@ -0,0 +1,415 @@ +"""Resolucion por capas: intencion -> parametros. + +El sistema NO es "spec -> parametros" sino un sistema de restricciones +parcialmente fijadas. W, L, Cm y W_M7M8 pueden venir dados o quedar libres, y +se resuelve solo lo libre. + +Tres regimenes: + sub-determinado hay familia de soluciones -> se elige por criterio + determinado solucion unica + sobre-determinado puede no haber solucion -> se ajusta lo fijo (warning) + o se reporta la contradiccion (error) + +Orden de ajuste cuando hay que tocar algo fijado, por coste de cambio: + 1. Cm solo un capacitor + 2. L_M5 transistor largo + 3. W_M5 afecta mas al layout +W_M7M8 queda fuera de la cadena: depende solo del fan-out y no compite con la +frecuencia ni el threshold (la carga no afecta a f, <0.7%). +""" +from __future__ import annotations + +import math + +from . import laws as L +from .spec import NeuronDesign, NeuronSpec, Severity + +# Punto nominal de la celda actual (sch/lif/neurona_input_current.sch). +# Es el unico con simulacion directa del circuito completo, asi que es el +# default mas honesto para "haz una neurona y ya". +NOMINAL = {"W_M5": 1.25, "L_M5": 50.0, "Cm": 150.0, "W_M7M8": 0.22} + + +def _clamp(v: float, lo: float, hi: float) -> tuple[float, bool]: + """Devuelve (valor acotado, si hubo que acotarlo).""" + if v < lo: + return lo, True + if v > hi: + return hi, True + return v, False + + +def design(spec: NeuronSpec) -> NeuronDesign: + """Resuelve una especificacion. Nunca lanza; ver NeuronDesign.notes.""" + d = NeuronDesign(params={}) + + # ---- caso trivial: sin objetivos -> punto nominal medido -------------- + if not spec.has_objectives() and not spec.fixed_dims(): + d.params = dict(NOMINAL) + d.add(Severity.INFO, "diseño", + "sin objetivos; se devuelve el punto nominal de la celda actual, " + "que es el unico con simulacion directa del circuito completo") + _predict(d, spec) + return d + + # ---- capa 1: geometria (W, L) desde la frecuencia -------------------- + W, Lg = _solve_geometry(spec, d) + + # ---- capa 2: Cm desde el threshold ----------------------------------- + Cm = _solve_cm(spec, d, W, Lg) + + # ---- capa 3: buffer de salida (independiente) ------------------------ + w78 = _solve_buffer(spec, d) + + d.params = {"W_M5": round(W, 3), "L_M5": round(Lg, 2), + "Cm": round(Cm, 1), "W_M7M8": round(w78, 3)} + + # ---- capa 4: validacion y prediccion --------------------------------- + _validate(d, W, Lg, Cm, spec) + _predict(d, spec) + return d + + +# -------------------------------------------------------------------------- +def _solve_geometry(spec: NeuronSpec, d: NeuronDesign) -> tuple[float, float]: + """(W, L) desde el objetivo de frecuencia, respetando lo que este fijo.""" + W, Lg = spec.W_M5, spec.L_M5 + + # que frecuencia se persigue, y a que corriente + f_target = iex_at = None + if (spec.freq_range and spec.iex_range + and spec.iex_range[1] != spec.iex_range[0]): + # el objetivo real es la GANANCIA: rango de salida / rango de entrada + k_req = ((spec.freq_range[1] - spec.freq_range[0]) / + (spec.iex_range[1] - spec.iex_range[0])) + f_target, iex_at = spec.freq_range[1], spec.iex_range[1] + d.add(Severity.INFO, "ganancia", + f"k requerida = {k_req:.3f} kHz/nA " + f"(f {spec.freq_range} kHz sobre Iex {spec.iex_range} nA)") + elif spec.freq_range and spec.iex_range: + # Iex fija: no hay pendiente que perseguir (seria 0/0), es un punto de + # operacion. Se dimensiona para esa frecuencia a esa corriente. + f_target, iex_at = spec.freq_range[1], spec.iex_range[1] + d.add(Severity.INFO, "frecuencia", + f"objetivo puntual: {f_target} kHz a {iex_at} nA") + elif spec.freq_range: + f_target, iex_at = spec.freq_range[1], L.IEX_REF + d.add(Severity.INFO, "frecuencia", + f"sin iex_range; se apunta a {f_target} kHz a {L.IEX_REF} nA") + + if f_target is None: + # sin objetivo de frecuencia: completar lo que falte con el nominal + return (W if W is not None else NOMINAL["W_M5"], + Lg if Lg is not None else NOMINAL["L_M5"]) + + # F_MAX es un limite medido, no de las leyes: sobre el, el reset no llega a + # completarse y la celda deja de disparar como predice la ley. Las leyes + # despejan igual y devuelven una geometria de aspecto razonable, asi que + # sin este aviso el motor entrega en silencio un diseño que no funciona. + if f_target > L.F_MAX: + d.add(Severity.WARNING, "frecuencia", + f"{f_target:.0f} kHz esta sobre el maximo medido " + f"({L.F_MAX:.0f} kHz): la geometria sale de las leyes, pero el " + f"reset no completa y la celda no llegara a esa frecuencia", + chain=f"F_MAX={L.F_MAX:.0f} kHz (el reset tarda ~215 ns)") + + if W is not None and Lg is not None: + # sobre-determinado: ambas fijas Y hay objetivo de frecuencia + f_real = L.freq(W, Lg, iex_at) + if abs(f_real - f_target) / f_target > spec.freq_tolerance: + _resolve_freq_conflict(spec, d, W, Lg, f_target, iex_at, f_real) + return _adjusted_geometry(spec, d, f_target, iex_at) + return W, Lg + + if W is not None: + Lg = L.solve_L_for_freq(W, f_target, iex_at) + Lg, hit = _clamp(Lg, L.L_MIN, L.L_MAX) + if hit: + # L sola no alcanza. Los objetivos mandan, asi que se libera W + # aunque el usuario la hubiera fijado. + w_new = L.solve_W_for_freq(Lg, f_target, iex_at) + w_new, hit_w = _clamp(w_new, L.W_MIN, L.W_MAX) + if not hit_w: + d.add(Severity.WARNING, "W_M5", + f"cambiada de {W} a {w_new:.3f} um para alcanzar " + f"{f_target:.0f} kHz", + f"con W={W} fija habria hecho falta L=" + f"{L.solve_L_for_freq(W, f_target, iex_at):.1f} um, " + f"fuera del rango {L.L_MIN}-{L.L_MAX}") + return w_new, Lg + f_got = L.freq(w_new, Lg, iex_at) + d.add(Severity.ERROR, "frecuencia", + f"{f_target:.0f} kHz inalcanzable ni ajustando W y L", + f"lo mas cercano con ambas en rango: {f_got:.0f} kHz " + f"({100*(f_got-f_target)/f_target:+.0f}%)") + return w_new, Lg + return W, Lg + + if Lg is not None: + W = L.solve_W_for_freq(Lg, f_target, iex_at) + W, hit = _clamp(W, L.W_MIN, L.W_MAX) + if hit: + # analogo: se libera L para no incumplir el objetivo + l_new = L.solve_L_for_freq(W, f_target, iex_at) + l_new, hit_l = _clamp(l_new, L.L_MIN, L.L_MAX) + if not hit_l: + d.add(Severity.WARNING, "L_M5", + f"cambiada de {Lg} a {l_new:.1f} um para alcanzar " + f"{f_target:.0f} kHz", + f"con L={Lg} fija habria hecho falta W fuera del rango " + f"{L.W_MIN}-{L.W_MAX}") + return W, l_new + f_got = L.freq(W, l_new, iex_at) + d.add(Severity.ERROR, "frecuencia", + f"{f_target:.0f} kHz inalcanzable ni ajustando W y L", + f"lo mas cercano con ambas en rango: {f_got:.0f} kHz " + f"({100*(f_got-f_target)/f_target:+.0f}%)") + return W, l_new + return W, Lg + + # ambas libres: hay un grado de libertad. Como gastarlo depende de si hay + # objetivo de threshold: Vth alto exige W*L pequeño (para que Cm_min sea + # bajo y Vth pueda subir), lo que compite con el criterio de margen. + return _pick_by_margin(d, f_target, iex_at, spec.vth) + + +def _pick_by_margin(d: NeuronDesign, f_target: float, iex_at: float, + vth_target: float | None = None) -> tuple[float, float]: + """Elige (W,L) sobre la curva de iso-frecuencia. + + Sin objetivo de Vth: se maximiza el margen a los limites duros, con L en + la zona precisa (>=25 um, donde el error es ~1% y no 5-7%). + + Con objetivo de Vth: se prefiere el punto que MAS Vth permite, es decir el + de menor Cm_min, es decir el de menor W*L. Solo si ninguno alcanza el Vth + pedido se cae de nuevo al criterio de margen. + """ + best, best_score = None, -1.0 + steps = 60 + for i in range(steps + 1): + Lg = L.L_PRECISE_MIN + (L.L_MAX - L.L_PRECISE_MIN) * i / steps + W = L.solve_W_for_freq(Lg, f_target, iex_at) + if not (L.W_MIN <= W <= L.W_MAX): + continue + if vth_target is not None: + # puntuar por cuanto Vth admite; el mejor es el de mayor techo + score = L.vth_max_at(W, Lg) + if score >= vth_target: + # alcanza: entre los que alcanzan, preferir el de mas margen + score = 1000.0 + min(math.log(W / L.W_MIN), + math.log(L.W_MAX / W)) + else: + # margen relativo al borde mas cercano, en escala log + mw = min(math.log(W / L.W_MIN), math.log(L.W_MAX / W)) + ml = min(math.log(Lg / L.L_PRECISE_MIN), math.log(L.L_MAX / Lg)) + score = min(mw, ml) + if score > best_score: + best, best_score = (W, Lg), score + if best is None: + # la frecuencia no es alcanzable con L en zona precisa; reintentar + # permitiendo L corta + for i in range(steps + 1): + Lg = L.L_MIN + (L.L_MAX - L.L_MIN) * i / steps + W = L.solve_W_for_freq(Lg, f_target, iex_at) + if L.W_MIN <= W <= L.W_MAX: + d.add(Severity.WARNING, "L_M5", + f"L={Lg:.1f} um esta bajo {L.L_PRECISE_MIN} um: el error " + "de la ley de frecuencia sube de ~1% a 5-7%") + return W, Lg + # nada alcanzable. El techo real es el menor entre lo que da la + # geometria minima y F_MAX (el reset no completa mas alla). + fmin = L.freq(L.W_MAX, L.L_MAX, iex_at) + fmax = min(L.freq(L.W_MIN, L.L_MIN, iex_at), L.F_MAX) + extra = "" + if L.freq(L.W_MIN, L.L_MIN, iex_at) > L.F_MAX: + extra = (f" (la geometria minima daria mas, pero sobre " + f"{L.F_MAX:.0f} kHz el reset no completa)") + d.add(Severity.ERROR, "frecuencia", + f"{f_target:.0f} kHz a {iex_at:.0f} nA no es alcanzable", + f"rango posible a esa corriente: {fmin:.0f} - {fmax:.0f} kHz" + + extra) + return NOMINAL["W_M5"], NOMINAL["L_M5"] + d.add(Severity.INFO, "geometria", + f"W={best[0]:.3f} L={best[1]:.1f} elegidas por margen de validez " + "(habia una familia de soluciones sobre la curva de iso-frecuencia)") + return best + + +def _resolve_freq_conflict(spec: NeuronSpec, d: NeuronDesign, W: float, + Lg: float, f_target: float, iex_at: float, + f_real: float) -> None: + """Informa el conflicto y que se puede liberar.""" + opts = [] + l_need = L.solve_L_for_freq(W, f_target, iex_at) + if L.L_MIN <= l_need <= L.L_MAX: + opts.append(f"liberar L_M5 -> L={l_need:.1f} um") + else: + opts.append(f"liberar L_M5 -> exigiria L={l_need:.1f} um (fuera de " + f"{L.L_MIN}-{L.L_MAX})") + w_need = L.solve_W_for_freq(Lg, f_target, iex_at) + if L.W_MIN <= w_need <= L.W_MAX: + opts.append(f"liberar W_M5 -> W={w_need:.3f} um") + else: + opts.append(f"liberar W_M5 -> exigiria W={w_need:.3f} um (fuera de " + f"{L.W_MIN}-{L.W_MAX})") + d.add(Severity.WARNING, "frecuencia", + f"W={W} y L={Lg} fijas dan {f_real:.0f} kHz, no {f_target:.0f}. " + "Los objetivos tienen prioridad, asi que se ajustan las dimensiones", + " | ".join(opts)) + + +def _adjusted_geometry(spec: NeuronSpec, d: NeuronDesign, f_target: float, + iex_at: float) -> tuple[float, float]: + """Ajusta la geometria priorizando el objetivo, tocando L antes que W.""" + W = spec.W_M5 + l_need = L.solve_L_for_freq(W, f_target, iex_at) + if L.L_MIN <= l_need <= L.L_MAX: + d.add(Severity.WARNING, "L_M5", + f"cambiada de {spec.L_M5} a {l_need:.1f} um para alcanzar " + f"{f_target:.0f} kHz") + return W, l_need + # L no alcanza: tocar W tambien + Lg, _ = _clamp(l_need, L.L_MIN, L.L_MAX) + w_need = L.solve_W_for_freq(Lg, f_target, iex_at) + w_need, hit = _clamp(w_need, L.W_MIN, L.W_MAX) + d.add(Severity.WARNING, "W_M5", + f"cambiada de {spec.W_M5} a {w_need:.3f} um; L_M5 tambien a " + f"{Lg:.1f} um") + if hit: + f_got = L.freq(w_need, Lg, iex_at) + d.add(Severity.ERROR, "frecuencia", + f"{f_target:.0f} kHz inalcanzable incluso ajustando ambas", + f"lo maximo con W,L en rango: {f_got:.0f} kHz") + return w_need, Lg + + +# -------------------------------------------------------------------------- +def _solve_cm(spec: NeuronSpec, d: NeuronDesign, W: float, Lg: float) -> float: + """Cm desde el threshold, o el minimo con margen si no se pidio.""" + floor = max(L.Cm_min(W, Lg), L.CM_FLOOR) + + if spec.vth is None: + Cm = spec.Cm if spec.Cm is not None else 1.2 * floor + if spec.Cm is not None and spec.Cm < floor: + d.add(Severity.WARNING, "Cm", + f"subida de {spec.Cm} a {floor:.0f} fF: bajo Cm_min la " + "membrana sale del riel", + f"Cm_min(W={W:.2f}, L={Lg:.1f}) = {L.Cm_min(W, Lg):.0f} fF") + Cm = floor + elif spec.Cm is None: + d.add(Severity.INFO, "Cm", + f"sin objetivo de Vth; se usa 1.2 x Cm_min = {Cm:.0f} fF " + "(margen sobre el limite de operacion)") + return Cm + + # hay objetivo de Vth + try: + Cm = L.solve_Cm_for_vth(W, Lg, spec.vth) + except ValueError as e: + d.add(Severity.ERROR, "Vth", str(e)) + return max(floor, NOMINAL["Cm"]) + + if Cm < floor: + # el Vth pedido exige menos Cm del permitido -> acoplamiento f/Vth + vmax = L.vth(W, Lg, floor) + d.add(Severity.ERROR, "Vth", + f"Vth={spec.vth:.3f} V exigiria Cm={Cm:.0f} fF, bajo el minimo " + f"de {floor:.0f} fF", + f"W*L={W*Lg:.0f} um2 -> Cm_min={L.Cm_min(W, Lg):.0f} fF -> " + f"Vth <= {vmax:.3f} V. La frecuencia y el threshold estan " + "acoplados: f baja exige W*L grande, que exige Cm grande, que " + "baja Vth") + return floor + if spec.Cm is not None and abs(spec.Cm - Cm) / Cm > 0.05: + d.add(Severity.WARNING, "Cm", + f"cambiada de {spec.Cm} a {Cm:.0f} fF para lograr " + f"Vth={spec.vth:.3f} V") + return Cm + + +def _solve_buffer(spec: NeuronSpec, d: NeuronDesign) -> float: + """W de M7/M8 desde el fan-out. Independiente del resto.""" + if spec.c_load is None: + w = spec.W_M7M8 if spec.W_M7M8 is not None else L.W_MIN + return w + need = L.solve_w_m7m8_for_load(spec.c_load) + if spec.W_M7M8 is not None: + if L.c_load_max(spec.W_M7M8) < spec.c_load: + d.add(Severity.WARNING, "W_M7M8", + f"subida de {spec.W_M7M8} a {need:.3f} um: con la fijada " + f"solo se manejan {L.c_load_max(spec.W_M7M8):.0f} fF de los " + f"{spec.c_load:.0f} pedidos") + return need + return spec.W_M7M8 + return need + + +# -------------------------------------------------------------------------- +def _validate(d: NeuronDesign, W: float, Lg: float, Cm: float, + spec: NeuronSpec) -> None: + """Limites duros sobre TODO, incluidas las dimensiones fijadas.""" + if not (L.W_MIN <= W <= L.W_MAX): + d.add(Severity.WARNING, "W_M5", + f"{W:.3f} um esta fuera del rango medido " + f"({L.W_MIN}-{L.W_MAX}); las leyes no estan validadas ahi") + if not (L.L_MIN <= Lg <= L.L_MAX): + d.add(Severity.WARNING, "L_M5", + f"{Lg:.1f} um esta fuera del rango medido " + f"({L.L_MIN}-{L.L_MAX})") + elif Lg < L.L_PRECISE_MIN: + d.add(Severity.WARNING, "L_M5", + f"{Lg:.1f} um: bajo {L.L_PRECISE_MIN} um el error de la ley de " + "frecuencia sube de ~1% a 5-7%") + if Cm < L.Cm_min(W, Lg): + d.add(Severity.WARNING, "Cm", + f"{Cm:.0f} fF esta bajo Cm_min={L.Cm_min(W, Lg):.0f} fF; la " + "membrana puede salir del riel (la ley es conservadora 10-25%, " + "asi que puede funcionar igualmente)") + v = L.vth(W, Lg, Cm) + if v >= L.VDD: + d.add(Severity.ERROR, "Vth", + f"el diseño da Vth={v:.2f} V, sobre VDD={L.VDD} V") + if spec.c_in_max is not None: + ci = L.c_in(W) + if ci > spec.c_in_max: + # solo se comprueba: C_in depende exclusivamente de W_M5, que es + # el ultimo eslabon de la cadena de ajuste. Resolverlo aqui seria + # gastar el mando mas caro por un margen de 1.1-4.0 fF. + d.add(Severity.WARNING, "C_in", + f"el diseño presenta C_in={ci:.2f} fF a la etapa previa, " + f"sobre el maximo pedido de {spec.c_in_max:.2f} fF; haria " + f"falta W_M5 <= {(spec.c_in_max - 0.945) / 0.865:.3f} um") + + +def _predict(d: NeuronDesign, spec: NeuronSpec) -> None: + """Comportamiento esperado y requisitos sobre el entorno.""" + W, Lg = d.params["W_M5"], d.params["L_M5"] + Cm, w78 = d.params["Cm"], d.params["W_M7M8"] + lo, hi = L.iex_window(W, Lg) + ir = spec.iex_range or (lo, min(hi, 200.0)) + + d.predicted = { + "k [kHz/nA]": round(L.gain(W, Lg), 3), + "f a 100 nA [kHz]": round(L.freq_at_iex_ref(W, Lg), 1), + "f en el rango [kHz]": (round(L.freq(W, Lg, ir[0]), 1), + round(L.freq(W, Lg, ir[1]), 1)), + "Vth [V]": round(L.vth(W, Lg, Cm), 3), + "swing [V]": round(L.swing(W, Lg, Cm), 3), + "Cm_min [fF]": round(L.Cm_min(W, Lg), 1), + "ventana Iex [nA]": (round(lo, 1), round(hi, 1)), + "C_in [fF]": round(L.c_in(W), 2), + "C_load max [fF]": round(L.c_load_max(w78), 1), + } + + iex_ref = ir[1] if spec.iex_range else L.IEX_REF + ro_need = L.min_source_impedance(iex_ref, 0.01) + d.requirements["impedancia de fuente"] = ( + f">= {ro_need/1e9:.2f} GOhm a {iex_ref:.0f} nA para 1% de error " + "(un espejo simple da 1-10 MOhm: hace falta cascodo o L larga)" + ) + if spec.source_ro is not None: + err = L.freq_error_from_source(iex_ref, spec.source_ro) + sev = Severity.WARNING if err > 0.02 else Severity.INFO + d.add(sev, "fuente", + f"con ro={spec.source_ro/1e6:.0f} MOhm el error de frecuencia " + f"sera ~{100*err:.1f}%") diff --git a/designs/scripts/lif_design/spec.py b/designs/scripts/lif_design/spec.py new file mode 100644 index 0000000..140492e --- /dev/null +++ b/designs/scripts/lif_design/spec.py @@ -0,0 +1,169 @@ +"""Contrato de entrada y salida del sistema de diseño. + +Entrada determinista: esto es una herramienta PARA que la use una IA, no una +IA. Quien llama expresa la intencion; aqui solo se resuelve con precision y se +informa con honestidad de lo que no se puede. + +Politica de prioridades (decidida por el equipo): + 1. OBJETIVOS de diseño -- mandan + 2. DIMENSIONES fijadas -- se ajustan si estorban, con WARNING + 3. Si la contradiccion no se puede resolver -> ERROR con la cadena causal +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + + +class Severity(str, Enum): + INFO = "info" # una decision que se tomo por el usuario + WARNING = "warning" # se cambio algo que el usuario habia fijado + ERROR = "error" # contradiccion irresoluble + + +@dataclass +class Note: + """Algo que el sistema decidio, cambio o no pudo hacer.""" + severity: Severity + subject: str # que parametro / objetivo + message: str # que paso + chain: str = "" # la cadena causal, cuando aplica + + def __str__(self) -> str: + s = f"[{self.severity.value.upper()}] {self.subject}: {self.message}" + if self.chain: + s += f"\n cadena: {self.chain}" + return s + + +def _as_range(v): + """Normaliza a (lo, hi): un escalar x se vuelve (x, x); None sigue None. + + Todo lo interno trabaja con pares, asi que las capas de resolucion no + tienen que distinguir si el usuario pidio un punto o un rango. + """ + if v is None: + return None + if isinstance(v, (int, float)): + return (float(v), float(v)) + lo, hi = v + return (float(lo), float(hi)) + + +@dataclass +class NeuronSpec: + """Lo que el diseñador pide. + + Todo es opcional. None significa "decide tu", no un default fijo -- la + distincion importa porque es lo que da libertad a las capas de resolucion. + + Objetivos (prioridad 1): + iex_range corriente que entregara la etapa previa [nA] + freq_range frecuencia deseada a la salida [kHz] + + Ambos aceptan un par (lo, hi) para pedir un rango, o un solo + numero para pedir ese valor exacto: freq_range=500 equivale a + freq_range=(500, 500). + vth umbral de disparo [V] + c_load carga capacitiva que colgara la etapa siguiente [fF] + + Dimensiones fijadas (prioridad 2, se ajustan con warning si estorban): + W_M5, L_M5, Cm, W_M7M8 + + Contexto: + source_ro impedancia de salida de la fuente de corriente [ohm]. + Si se da, el sistema calcula el error esperado. + c_in_max capacidad maxima que la etapa previa puede manejar [fF]. + Dual de c_load: nuestro c_load es el C_in de la celda + siguiente, y nuestro C_in es el c_load de la anterior. + Se comprueba, no se resuelve: C_in solo va de 1.1 a 4.0 fF + en todo el envolvente, asi que la cota practicamente nunca + puede morder. Si algun dia lo hace, sube a objetivo y + compite con la frecuencia por W_M5. + freq_tolerance desviacion aceptable al resolver [fraccion] + """ + # objetivos + iex_range: tuple[float, float] | float | None = None + freq_range: tuple[float, float] | float | None = None + vth: float | None = None + c_load: float | None = None + + # dimensiones fijadas + W_M5: float | None = None + L_M5: float | None = None + Cm: float | None = None + W_M7M8: float | None = None + + # contexto + source_ro: float | None = None + c_in_max: float | None = None + freq_tolerance: float = 0.05 + + def __post_init__(self) -> None: + self.iex_range = _as_range(self.iex_range) + self.freq_range = _as_range(self.freq_range) + + def fixed_dims(self) -> dict[str, float]: + """Las dimensiones que el usuario fijo explicitamente.""" + return { + n: v for n, v in ( + ("W_M5", self.W_M5), ("L_M5", self.L_M5), + ("Cm", self.Cm), ("W_M7M8", self.W_M7M8), + ) if v is not None + } + + def has_objectives(self) -> bool: + return any(x is not None for x in + (self.iex_range, self.freq_range, self.vth, self.c_load)) + + +@dataclass +class NeuronDesign: + """Lo que el sistema devuelve. + + NUNCA lanza excepcion: siempre trae params con la mejor solucion + alcanzable. Un agente que consume esto necesita datos estructurados sobre + el conflicto, no un stack trace. + """ + params: dict[str, float] # W_M5, L_M5, Cm, W_M7M8 + predicted: dict[str, object] = field(default_factory=dict) + requirements: dict[str, object] = field(default_factory=dict) + notes: list[Note] = field(default_factory=list) + + @property + def ok(self) -> bool: + """False si hubo alguna contradiccion irresoluble.""" + return not any(n.severity is Severity.ERROR for n in self.notes) + + @property + def errors(self) -> list[Note]: + return [n for n in self.notes if n.severity is Severity.ERROR] + + @property + def warnings(self) -> list[Note]: + return [n for n in self.notes if n.severity is Severity.WARNING] + + def add(self, severity: Severity, subject: str, message: str, + chain: str = "") -> None: + self.notes.append(Note(severity, subject, message, chain)) + + def report(self) -> str: + """Resumen legible. Para consumo por humano; una IA usa los campos.""" + lines = ["=" * 62, + "DISEÑO " + ("OK" if self.ok else "CON ERRORES"), + "=" * 62, "", "Parametros:"] + for k, v in self.params.items(): + unit = "fF" if k == "Cm" else "um" + lines.append(f" {k:10s} = {v:8.3f} {unit}") + if self.predicted: + lines += ["", "Comportamiento predicho:"] + for k, v in self.predicted.items(): + lines.append(f" {k:18s} = {v}") + if self.requirements: + lines += ["", "Requisitos sobre el entorno:"] + for k, v in self.requirements.items(): + lines.append(f" {k:18s} : {v}") + if self.notes: + lines += ["", "Notas:"] + lines += [f" {n}" for n in self.notes] + return "\n".join(lines) diff --git a/designs/scripts/run_GL_tmp.sh b/designs/scripts/run_GL_tmp.sh new file mode 100644 index 0000000..858171d --- /dev/null +++ b/designs/scripts/run_GL_tmp.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# Instala gLayout y arranca Jupyter, como run_GL_conda.sh y run_GL_pyenv.sh, +# pero desde la rama con los arreglos y sobre el python que ya trae la imagen. +# +# bash /foss/designs/scripts/run_GL_tmp.sh +# +# TEMPORAL: esto sobra el dia que entren los PR 100, 102, 103, 104, 113 +# y 114 en +# ReaLLMASIC/gLayout. Mientras tanto hace falta, por tres razones: +# +# 1. glayout de upstream NO ARRANCA en esta imagen. Su backend por defecto +# es gdsfactory, y el 9.40 que trae la imagen ya no expone +# gdsfactory.component_reference, asi que glayout cae a un DummyPdk sin +# fallar del todo: parece que funciona y no funciona. Con +# GLAYOUT_BACKEND=gdstk si arranca, y por eso la variable no es opcional. +# +# 2. En gf180 el mimcap sale con las placas EN CORTO si le falta el +# FuseTop que define el dielectrico. No lo detecta el DRC ni la +# extraccion habitual, asi que se comprueba al arrancar. +# +# 3. Los transistores por debajo de ~0.36 um de ancho no se pueden +# construir. +# +# Los dos scripts que ya hay llevan a un glayout con esos tres problemas: +# run_GL_conda.sh hace `pip install glayout` desde PyPI, y run_GL_pyenv.sh +# instala designs/libs/gLayout, que es un clon de upstream main. +# +# Este no toca ninguno de los dos ni sus entornos. Instala aparte. +set -euo pipefail + +REPO="${GLAYOUT_REPO:-https://github.com/carloscl03/gLayout.git}" +RAMA="${GLAYOUT_RAMA:-capimagics-optionB}" +DESTINO="${GLAYOUT_DIR:-/tmp/glayout}" +VENV="${GLAYOUT_VENV:-/tmp/venv}" +SALIDA_NB="${LIF_OUT:-/tmp/nbout}" +PUERTO="${JUPYTER_PORT_INT:-8888}" +LANZAR="${LANZAR_JUPYTER:-1}" + +echo "== gLayout desde ${REPO} rama ${RAMA}" +if [ -d "${DESTINO}/.git" ]; then + # reset --hard, no checkout: este clon es desechable y tiene que quedar + # igual que la rama pase lo que pase. Un checkout aborta si alguien dejo + # cambios locales ahi -- y entonces el script falla a medias, dejando el + # venv apuntando a un glayout que no es el que dice ser. + # Refspec explicito: el clon es --depth 1 de UNA rama, asi que su + # refspec no cubre las demas y un `fetch origin otra-rama` deja el + # commit en FETCH_HEAD sin crear origin/otra-rama. Con el destino + # escrito a mano la referencia existe siempre. + git -C "${DESTINO}" fetch -q origin "${RAMA}:refs/remotes/origin/${RAMA}" + git -C "${DESTINO}" reset -q --hard "origin/${RAMA}" + git -C "${DESTINO}" clean -qfd +else + rm -rf "${DESTINO}" + git clone -q --depth 1 -b "${RAMA}" "${REPO}" "${DESTINO}" +fi +git -C "${DESTINO}" log --oneline -1 + +echo "== venv sobre el python de la imagen (ya trae gdstk, klayout, matplotlib)" +[ -x "${VENV}/bin/python" ] || python3 -m venv --system-site-packages "${VENV}" +# --no-deps a proposito: lo que hace falta ya viene en la imagen, y resolver +# las dependencias otra vez arrastra un gdsfactory que no queremos. +"${VENV}/bin/pip" install -q --no-deps -e "${DESTINO}" + +echo "== comprobacion" +GLAYOUT_BACKEND=gdstk PYTHONPATH= LD_LIBRARY_PATH= "${VENV}/bin/python" - <<'PY' +import os + +import glayout +from glayout import gf180 + +print(" glayout: ", os.path.dirname(glayout.__file__)) +# Se comprueba el RESULTADO, no el mapeo. Con el mimcap anterior el +# dielectrico salia de `capmet`, y apuntar a CAP_MK dejaba las placas en +# corto; desde el PR #106 de glayout CAP_MK es el valor correcto -- es el +# marcador -- y el dielectrico lo define el FuseTop que dibuja el propio +# mimcap. Mirar la capa dibujada vale para los dos casos. +import os as _os, tempfile as _tmp +import gdstk as _gdstk +from glayout.primitives.mimcap import mimcap as _mimcap +_c = _mimcap(gf180, size=(5.0, 5.0)) +with _tmp.TemporaryDirectory() as _d: + _p = _os.path.join(_d, "m.gds") + _c.write_gds(_p) + _top = _gdstk.read_gds(_p).top_level()[0] + _top.flatten() + _capas = {(x.layer, x.datatype) for x in _top.polygons} +assert (75, 0) in _capas, "el MIM sale sin FuseTop: este glayout genera los condensadores en corto" +print(" MIM con FuseTop: el dielectrico esta definido") + +from glayout.primitives.fet import nmos +nmos(gf180, width=0.22, length=0.28, multipliers=1, fingers=1, + with_dnwell=False, with_substrate_tap=False, with_dummy=False) +print(" fet de 0.22 um: se construye") +PY + +echo "== kernel de jupyter" +# Se llama 'lif' y no 'GLdev' a proposito: no pisa el kernel que registran los +# otros scripts. La contrapartida es que hay que elegirlo a mano al abrir un +# notebook, porque los notebooks declaran GLdev. +# +# Las variables van DENTRO del kernel. Si dependieran de exportarlas en la +# terminal, abrir el notebook desde Jupyter fallaria y no seria obvio por que. +# PYTHONPATH y LD_LIBRARY_PATH vacios porque la imagen los trae puestos y se +# colarian segun desde que shell se lance -- los scripts del equipo empiezan +# con un unset por lo mismo. +"${VENV}/bin/python" -m ipykernel install --user --name lif \ + --display-name 'LIF motor (gdstk)' >/dev/null 2>&1 +"${VENV}/bin/python" - </dev/null || netstat -ltn 2>/dev/null) | grep -q ":${PUERTO}\b"; then + echo + echo "== ya hay un Jupyter escuchando en el ${PUERTO} DENTRO del contenedor." + echo " No lanzo otro. Probablemente sea el que quiere: uselo." + echo + echo " OJO: ${PUERTO} es el puerto INTERNO. Desde su maquina hay que" + echo " entrar por el que este mapeado a el, que casi nunca coincide." + echo " Para verlo, desde fuera del contenedor:" + echo + echo " docker port \$(hostname) ${PUERTO}" + echo + echo " Si el que hay no es el suyo, matelo y repita. NO cambie el" + echo " puerto interno: el unico que su contenedor expone al exterior es" + echo " este, asi que en otro puerto Jupyter arranca pero no se alcanza." + exit 0 +fi + +# Si hay escritorio (o sea, estamos dentro del VNC), que Jupyter abra la +# ventana ahi mismo. Sin esto solo escupe una URL, y esa URL lleva al puerto +# INTERNO, que desde fuera del contenedor no es el que hay que teclear. +NAVEGADOR="--no-browser" +if [ -n "${DISPLAY:-}" ] && command -v firefox >/dev/null 2>&1; then + NAVEGADOR="" + echo "== Jupyter en el ${PUERTO}, abriendo la ventana en el escritorio" +else + echo "== Jupyter en el puerto ${PUERTO} (token: lif)" + echo " Ese es el puerto INTERNO del contenedor. Desde su maquina entre" + echo " por el que este mapeado a el: docker port \$(hostname) ${PUERTO}" +fi + +cd /foss/designs +# shellcheck disable=SC2086 +exec env PYTHONPATH= LD_LIBRARY_PATH= BROWSER=firefox \ + "${VENV}/bin/python" -m jupyterlab \ + --ip=0.0.0.0 --port="${PUERTO}" ${NAVEGADOR} \ + --IdentityProvider.token=lif --ServerApp.root_dir=/foss/designs diff --git a/layout/lif/neurona_abe.png b/layout/lif/neurona_abe.png new file mode 100644 index 0000000..4839a2b Binary files /dev/null and b/layout/lif/neurona_abe.png differ diff --git a/layout/lif/neurona_variantes.png b/layout/lif/neurona_variantes.png new file mode 100644 index 0000000..bdf47ec Binary files /dev/null and b/layout/lif/neurona_variantes.png differ diff --git a/sch/lif/pyproject.toml b/sch/lif/pyproject.toml new file mode 100644 index 0000000..f23ce57 --- /dev/null +++ b/sch/lif/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "lif-design" +version = "0.1.0" +description = "Design system for the LIF neuron cell (GF180MCU)" +requires-python = ">=3.9" +# Sin dependencias a proposito: solo stdlib. Son ~15 pow() por diseño y +# arrastrar numpy costaria 100 ms de importacion para un calculo de 50 us. +dependencies = [] + +[tool.setuptools] +packages = ["design"] diff --git a/sch/lif/results/cm_limit_bisect.csv b/sch/lif/results/cm_limit_bisect.csv new file mode 100644 index 0000000..40245aa --- /dev/null +++ b/sch/lif/results/cm_limit_bisect.csv @@ -0,0 +1,13 @@ +W_M5,L_M5,area,Cm_min_fF +0.5,25u,12.5,44 +0.5,41u,20.5,59 +0.75,33u,24.75,73 +1.0,25u,25.0,88 +1.0,41u,41.0,117 +1.0,50u,50.0,132 +1.25,33u,41.25,132 +1.5,25u,37.5,117 +1.5,41u,61.5,206 +1.75,50u,87.5,235 +2.0,33u,66.0,206 +2.5,41u,102.5,337 diff --git a/sch/lif/results/cm_limit_lm5.csv b/sch/lif/results/cm_limit_lm5.csv new file mode 100644 index 0000000..03d6b70 --- /dev/null +++ b/sch/lif/results/cm_limit_lm5.csv @@ -0,0 +1,16 @@ +L_M5_um,Cm_f,freq_kHz,Vm_min,Vm_max,estado +25u,75f,1093.4,-0.406,1.935,ANOMALO +25u,100f,1112.8,-0.010,1.771,OK +25u,125f,1154.9,0.255,1.671,OK +25u,150f,1280.2,0.435,1.608,OK +25u,200f,1290.8,0.658,1.520,OK +35u,75f,792.2,-0.638,2.260,ANOMALO +35u,100f,827.8,-0.276,2.048,ANOMALO +35u,125f,904.2,0.153,1.894,OK +35u,150f,863.1,0.338,1.791,OK +35u,200f,890.1,0.627,1.648,OK +50u,75f,599.0,-0.776,2.890,ANOMALO +50u,100f,523.8,-0.638,2.488,ANOMALO +50u,125f,538.3,-0.395,2.255,ANOMALO +50u,150f,545.3,-0.050,2.088,OK +50u,200f,554.5,0.319,1.882,OK diff --git a/sch/lif/results/cm_limit_map.csv b/sch/lif/results/cm_limit_map.csv new file mode 100644 index 0000000..7d3b6df --- /dev/null +++ b/sch/lif/results/cm_limit_map.csv @@ -0,0 +1,25 @@ +Vin,Iex_nA,Cm_f,freq_kHz,Vm_min,Vm_max,fuera_rieles_V,estado +1.5,193.5,50,1488.3,-0.810,3.478,0.988,ANOMALO +1.5,193.9,75,1115.8,-0.772,2.892,0.772,ANOMALO +1.5,194.1,100,1003.5,-0.619,2.501,0.619,ANOMALO +1.5,194.0,125,1027.0,-0.374,2.258,0.374,ANOMALO +1.5,194.0,150,1022.2,-0.031,2.091,0.031,OK +1.5,194.0,200,1033.0,0.327,1.888,0.000,OK +1.8,100.4,50,831.9,-0.809,3.555,1.064,ANOMALO +1.8,100.5,75,599.0,-0.776,2.890,0.776,ANOMALO +1.8,100.5,100,523.8,-0.638,2.488,0.638,ANOMALO +1.8,100.5,125,538.3,-0.395,2.255,0.395,ANOMALO +1.8,100.5,150,545.3,-0.050,2.088,0.050,OK +1.8,100.5,200,554.5,0.319,1.882,0.000,OK +2.1,37.6,50,334.8,-0.810,3.568,1.078,ANOMALO +2.1,37.5,75,238.9,-0.769,2.873,0.769,ANOMALO +2.1,37.5,100,210.0,-0.640,2.487,0.640,ANOMALO +2.1,37.5,125,200.3,-0.294,2.232,0.294,ANOMALO +2.1,37.5,150,207.6,-0.013,2.083,0.013,OK +2.1,37.5,200,218.7,0.306,1.880,0.000,OK +2.3,12.5,50,121.2,-0.808,3.548,1.057,ANOMALO +2.3,12.5,75,85.5,-0.739,2.863,0.739,ANOMALO +2.3,12.5,100,70.3,-0.653,2.446,0.653,ANOMALO +2.3,12.5,125,66.2,-0.258,2.238,0.258,ANOMALO +2.3,12.5,150,66.2,0.007,2.078,0.000,OK +2.3,12.5,200,75.7,0.342,1.877,0.000,OK \ No newline at end of file diff --git a/sch/lif/results/cm_limit_wl.csv b/sch/lif/results/cm_limit_wl.csv new file mode 100644 index 0000000..e692ace --- /dev/null +++ b/sch/lif/results/cm_limit_wl.csv @@ -0,0 +1,31 @@ +W_M5,L_M5,Cm_f,Vm_min,estado +0.5,25u,100f,0.804,OK +0.5,25u,150f,0.993,OK +0.5,25u,200f,1.075,OK +0.5,25u,300f,1.145,OK +0.5,25u,400f,1.188,OK +0.5,50u,100f,0.538,OK +0.5,50u,150f,0.781,OK +0.5,50u,200f,0.861,OK +0.5,50u,300f,1.011,OK +0.5,50u,400f,1.089,OK +1.25,50u,100f,-0.638,ANOMALO +1.25,50u,150f,-0.050,OK +1.25,50u,200f,0.319,OK +1.25,50u,300f,0.613,OK +1.25,50u,400f,0.775,OK +1.75,33u,100f,-0.651,ANOMALO +1.75,33u,150f,-0.125,ANOMALO +1.75,33u,200f,0.267,OK +1.75,33u,300f,0.543,OK +1.75,33u,400f,0.725,OK +1.75,41u,100f,-0.788,ANOMALO +1.75,41u,150f,-0.641,ANOMALO +1.75,41u,200f,-0.248,ANOMALO +1.75,41u,300f,0.286,OK +1.75,41u,400f,0.551,OK +2.5,25u,100f,-0.608,ANOMALO +2.5,25u,150f,-0.253,ANOMALO +2.5,25u,200f,0.142,OK +2.5,25u,300f,0.552,OK +2.5,25u,400f,0.731,OK diff --git a/sch/lif/results/crossval_drive.csv b/sch/lif/results/crossval_drive.csv new file mode 100644 index 0000000..dbfe660 --- /dev/null +++ b/sch/lif/results/crossval_drive.csv @@ -0,0 +1,7 @@ +config,L_M5,Cm,W_out,i_pullup_uA,i_pulldown_uA +A,25u,150f,0.22,23.92,24.39 +A,25u,150f,1.0,84.27,87.19 +A,25u,150f,4.0,342.66,349.87 +B,50u,150f,0.22,23.7,23.27 +B,50u,150f,1.0,84.2,86.89 +B,50u,150f,4.0,343.75,354.35 diff --git a/sch/lif/results/crossval_freq.csv b/sch/lif/results/crossval_freq.csv new file mode 100644 index 0000000..6783b47 --- /dev/null +++ b/sch/lif/results/crossval_freq.csv @@ -0,0 +1,13 @@ +config,L_M5,Cm,Vin,Iex_nA,freq_kHz,Vth_V +A,25u,150f,1.5,194.0,2404.5,1.615 +A,25u,150f,1.7,128.3,1578.9,1.608 +A,25u,150f,1.9,76.1,929.8,1.604 +A,25u,150f,2.1,37.5,459.9,1.601 +B,50u,150f,1.5,194.0,1022.2,2.091 +B,50u,150f,1.7,128.3,677.5,2.090 +B,50u,150f,1.9,76.1,427.0,2.085 +B,50u,150f,2.1,37.5,207.6,2.083 +C,35u,200f,1.5,194.0,1629.1,1.670 +C,35u,200f,1.7,128.3,1093.8,1.666 +C,35u,200f,1.9,76.1,649.4,1.662 +C,35u,200f,2.1,37.5,321.7,1.654 diff --git a/sch/lif/results/crossval_iexvin.csv b/sch/lif/results/crossval_iexvin.csv new file mode 100644 index 0000000..d969916 --- /dev/null +++ b/sch/lif/results/crossval_iexvin.csv @@ -0,0 +1,16 @@ +config,L_M5,Cm,Vin,Iex_nA +A,25u,150f,1.5,193.99 +A,25u,150f,1.7,128.27 +A,25u,150f,1.9,76.11 +A,25u,150f,2.1,37.52 +A,25u,150f,2.3,12.50 +B,50u,150f,1.5,194.03 +B,50u,150f,1.7,128.30 +B,50u,150f,1.9,76.13 +B,50u,150f,2.1,37.53 +B,50u,150f,2.3,12.50 +C,35u,200f,1.5,193.99 +C,35u,200f,1.7,128.27 +C,35u,200f,1.9,76.11 +C,35u,200f,2.1,37.52 +C,35u,200f,2.3,12.50 diff --git a/sch/lif/results/lif_knowledge_base.md b/sch/lif/results/lif_knowledge_base.md new file mode 100644 index 0000000..740450d --- /dev/null +++ b/sch/lif/results/lif_knowledge_base.md @@ -0,0 +1,413 @@ +# LIF Neuron — Design Knowledge Base + +Empirical characterization of the LIF neuron (GF180MCU, 7T) mapping **design +parameters to behaviour**. Every number here comes from ngspice transient +simulation with multi-cycle period averaging. + +Cell under test: [`../neurona_input_current.sch`](../neurona_input_current.sch). +Current input — the cell receives `Iex` directly, so it carries no input mirror. + +Reference point: `W_M5 = 1.25 µm`, `L_M5 = 50 µm`, `Cm = 150 fF`, inverters at +PDK minimum (`W/L = 0.22/0.28 µm`). + +> **Simulation settings are not a detail.** Use `.tran 1n` and a transient long +> enough for ≥5 cycles. Coarse settings produced two separate false results +> during this work — see [Methodology](#6-methodology-two-costly-artifacts). + +--- + +## 1. The design laws + +Six laws describe the cell. All were fitted on `.tran 1n` data and validated +against points outside the fitting grid. + +### Frequency + +```math +f[\text{kHz}] = 24837 \cdot W_{M5}^{-1.076} \cdot L_{M5}^{-0.940} \cdot \frac{I_{ex}}{100\,\text{nA}} +``` + +RMS 2.03% over 69 points. **`Cm` does not appear** — adding it to the fit makes +it worse. Verified across 8 series: `f` varies less than 4% while `Cm` triples. + +| Cm (W=1.75, L=41) | 280 f | 388 f | 561 f | 864 f | +|---|---|---|---|---| +| f [kHz] | 414.2 | 411.6 | 410.3 | 411.5 | + +The relation to current is **proportional, with no intercept** (see +[§4](#4-corrections-to-earlier-conclusions)). + +### Modulation gain + +```math +k[\text{kHz/nA}] = 280.22 \cdot W_{M5}^{-1.0447} \cdot L_{M5}^{-0.9923} +``` + +RMS 2.18%, worst case 4.9%, over 9 configurations. This is the slope a designer +actually needs: an input current *range* maps to an output frequency *range*. + +Known bias: `k` has curvature — the slope falls as current rises (13.33 → 10.47 +kHz/nA within one series). At the low end the real gain is **~11.6% higher** +than this law (measured 16.41 vs 14.51 at 5–10 nA for W=0.5, L=41). + +### Threshold + +```math +V_{th}[\text{V}] = 1.2792 + \frac{-16.83\,W_{M5} + 0.4884\,L_{M5} + 1.766\,W_{M5}L_{M5}}{C_m} +``` + +RMS 1.32%. **Orthogonal to `Iex`**: varies under 1.2% while current changes 16×. +The cross term is required — dropping the linear terms drops R² to 0.806. + +### Membrane swing + +```math +\text{swing}[\text{V}] = 4.114 \cdot W_{M5}^{0.951} \cdot L_{M5}^{1.065} \cdot C_m^{-1.006} +``` + +RMS 1.68%. The exponents land on (+1, +1, −1), so this is `W·L/Cm` — coupled +charge over capacitance. Physics, not curve fitting. + +### Minimum capacitance + +```math +C_{m,min}[\text{fF}] = 8.94 \cdot W_{M5}^{1.038} \cdot L_{M5}^{0.700} +``` + +Below this the membrane swings outside the rails and the cell misbehaves. +Conservative by 10–25%: measured boundaries sit at 0.75–0.93× the predicted +value across four configurations. + +### Output stage + +```math +C_{load,max}[\text{fF}] \approx 600 \cdot W_{M7M8}[\mu\text{m}] \qquad +I_{drive}[\mu\text{A}] \approx 85 \cdot W_{M7M8} +``` + +Criterion: fall time ≤ 5 ns. **Load does not feed back into the loop** — +frequency shifts under 0.7% with `C_load` from 0 to 1600 fF. + +The inverter is balanced: pull-up (M7) matches pull-down (M8) at equal W, so +the spike is symmetric. Drive is independent of everything else — re-measured at +`L_M5 = 25 µm` it gives 84.97 vs 85.07 µA/µm, under 1% apart. + +### Input capacitance + +```math +C_{in}[\text{fF}] = 0.945 + 0.865 \cdot W_{M5}[\mu\text{m}] +``` + +Measured as `C_total − Cm`, where `C_total = Iex / (dV/dt)` on the integration +ramp — the current source charging the node *is* the measurement. LOO 0.67% +RMS, external validation 0.56% RMS on a disjoint grid. + +**Affine, not a power law**, for the same reason as `Vth`: there is a physical +constant term. The 0.945 is the M1/M2 gate pair, which hangs off the node even +at minimum M5; the `0.865·W` is M5's drain junction. A pure power law is forced +through the origin and misses by −21% at `W = 0.22`. Only `W` enters — four +pairs of `L` measured, under 1.5% apart. + +**Do not use it to correct `f`.** The frequency law was fitted on full-circuit +simulations that already contain this `C_in`; adding it again double-counts. +Its uses are the interface contract (this is the `c_load` the previous stage +must drive) and as the baseline against which layout interconnect parasitics +are measured once the GDS is extracted. + +`C_in` is the dual of `c_load`: our `c_load` is the next cell's `C_in`, and our +`C_in` is the previous cell's `c_load`. In the design system it is a *predicted +output*, never an objective — a `cin_max` in the spec is checked, not solved, +because 1.1–4.0 fF over the whole envelope is too narrow a band for the +constraint to ever bind. + +--- + +## 2. Structure of the design space + +Three properties that are not obvious from the schematic. + +### `Cm` is not a frequency knob + +It sets **threshold and swing**, nothing else. This breaks what would otherwise +be a circular dependency and makes the whole design problem solvable by direct +substitution — no iteration. + +### Frequency and threshold are physically coupled + +``` +f low → W·L large → Cm_min large → Cm large → Vth low +``` + +So a slow neuron cannot have a high threshold. Measured ceiling: + +| f target | max Vth | +|---|---| +| 200 kHz | 1.81 V | +| 500 kHz | 1.91 V | +| 1000 kHz | 2.04 V | +| 3000 kHz | 2.53 V | + +Validated by simulation at three frequencies: predicted vs measured `Vth` at the +boundary agrees to −0.5% … −2.9%. + +### One degree of freedom + +For a given frequency there are ~200 valid `(W, L)` pairs — the iso-frequency +curve. Since the exponents are close (−1.076 vs −0.940), `W·L` is roughly +constant along it. That freedom is what a design system spends on a secondary +criterion: area, validity margin, or maximum achievable `Vth`. + +### Coupling matrix + +| Knob | frequency | threshold | notes | +|---|---|---|---| +| `Iex` | primary, linear | **<1.2%** | the only clean knob | +| `W_M5` | strong, `∝W^-1.08` | strong, via `W·L` | first order on both | +| `L_M5` | strong, `∝L^-0.94` | moderate | | +| `Cm` | **none** | primary | sets swing too | +| `W_M7M8` | none | none | fan-out only | + +--- + +## 3. Operating limits + +Measured, not assumed. Values marked ✅ have a directly measured boundary. + +| Parameter | Limit | What happens outside | +|---|---|---| +| `f` | **≤ ~4500 kHz** ✅ | reset does not complete; period floor ~215 ns | +| `W_M5` | **≤ 3.5 µm** ✅ | at 4.0 µm `Vm_min` = −0.058 V; depends on `Cm` | +| `W_M5` | ≥ 0.22 µm | PDK minimum | +| `L_M5` | **≤ 50 µm** ✅ | L=60 does not converge | +| `L_M5` | ≥ 20 µm ✅ | below 25 µm the frequency error rises to 5–7% | +| `Cm` | ≥ `Cm_min(W,L)` ✅ | membrane leaves the rails | +| `Cm` | ≥ 50 fF | the `Vth` law diverges (at 25 fF it predicts 5.83 V > VDD) | +| `Iex` | **no floor** ✅ | verified down to 5 nA with constant gain and swing | + +The frequency ceiling is a **period** limit, not a current one: three +configurations died at 350, 500 and 600 nA but all around 4400–4600 kHz. + +Two checks any design flow must perform: + +1. `Vth < VDD` — the `.../Cm` form has no ceiling, but physics does. +2. `Cm > Cm_min(W,L)` — the constraint that couples W, L and Cm, and the one + most often violated when asking for a low threshold. + +### Source impedance — the strictest requirement + +The current source feeding the cell needs a very high output impedance. A finite +`ro` injects parasitic current proportional to the drop across it, and the +membrane node swings ~1.9 V below VDD: + +```math +r_o \geq \frac{1.9\,\text{V}}{\text{tol} \cdot I_{ex}} \qquad\Rightarrow\qquad r_o[\text{G}\Omega] \geq \frac{190}{I_{ex}[\text{nA}]} \;\text{ for } 1\% +``` + +Measured at 100 nA (W=1.0, L=41, Cm=200 fF): + +| `ro` | frequency | deviation | +|---|---|---| +| ∞ (ideal) | 758.2 kHz | — | +| 1 GΩ | 775.2 | +2.2% | +| 100 MΩ | 930.5 | +22.7% | +| 30 MΩ | 1325.9 | +74.9% | +| 10 MΩ | 2404.6 | +217% | +| 3 MΩ | — | **stops oscillating** | + +**A simple mirror (1–10 MΩ) is not enough** — a cascode or a long-channel device +is required. Low currents are the demanding case: at 25 nA even 1 GΩ gives 9.2% +error. + +--- + +## 4. Corrections to earlier conclusions + +Findings reported during this work and later proved wrong. Kept because the +reasoning matters more than the conclusions. + +| Earlier claim | Reality | +|---|---| +| "`Cm` raises frequency up to +79%" | flat in `Cm` (<4%); the effect was a timestep artifact | +| "Jitter of 30–55% limits the design" | numerical, not physical; vanishes at 1 ns | +| "`Iex` has a floor from M5 leakage" | no floor; verified to 5 nA. Short-window artifact | +| "`f` saturates above W=2.5 µm" | it does not saturate — the circuit breaks (membrane leaves the rail) | +| "`f = k·Iex + f₀` with f₀ = 14–144 kHz" | **f₀ = 0**. The intercept was an artifact of fitting far from the origin | + +The `f₀` case is instructive. Fitting straight lines over 25–400 nA produced +intercepts of 14–144 kHz. Measuring directly at 5 and 10 nA gives +**f₀ = +0.57 kHz** — zero. Extrapolating the intercept-bearing line downward +fails badly: + +| Iex | line with f₀ | proportional | measured | +|---|---|---|---| +| 5 nA | 129.2 (+56%) | 79.8 (−3.4%) | 82.6 | +| 10 nA | 204.7 (+24%) | 159.6 (−3.1%) | 164.6 | + +An intercept fitted far from the origin absorbs curvature from the high end. + +--- + +## 5. Validation + +### External validation — 18 points no law had seen + +Grid deliberately disjoint from the fitting grid: `W ∈ {0.75, 1.4, 2.1}` × +`L ∈ {33, 50}`, where the fit used `W ∈ {0.5, 1.0, 1.75, 2.5}` × `L ∈ {25, 41}`. +`L = 50 µm` also sits outside the fitted range, testing extrapolation. + +| Law | mean error | RMS | max | +|---|---|---|---| +| `f` | −0.00% | 1.23% | 2.59% | +| `Vth` | −0.63% | 0.90% | 1.61% | +| `swing` | −0.58% | 1.11% | 1.88% | +| `C_in` | +0.40% | 0.56% | 0.98% | + +`C_in` used the same grids plus two points at `W = 0.3` and `W = 0.22`, below +the fitting grid's lower edge of 0.5 — a 2.3× extrapolation. The affine law +held there (−0.06%, +0.98%) while a power law fitted on the same points +collapsed (−14%, −21%). Note that this 0.56% measures the law on the surface +`Cm = 2·Cm_min(W,L)`, where both grids live; off that surface it deviates up to +15%. The number that bounds the deliverable is 0.15% of frequency — see +section 8. + +Extrapolation-only subset (L=50): −1.08%, −1.04%, +0.11%. **The laws do not +break outside their fitting range**, and the LOO estimate of 3.1% turned out +conservative. + +The `Iex` law from the previous voltage-input topology was re-measured at 1 ns +and holds to **0.07% RMS**: +`Iex[nA] = 169.1·(2.571 − Vin)²`. It now describes a block that lives *outside* +the cell, kept here as a reference for whoever designs the input stage. + +### Design-loop validation + +Three designs generated by the design system, then simulated. Two use +geometries that appear in no characterization grid: + +| design | W | L | Cm | f predicted | f measured | error | +|---|---|---|---|---|---|---| +| nominal | 1.25 | 50.0 | 150 | 494.1 | 500.9 | −1.4% | +| fast | 0.602 | 35.42 | 76.9 | 1499.5 | 1491.6 | +0.5% | +| slow | 2.835 | 40.42 | 421.6 | 250.0 | 252.5 | −1.0% | + +Worst error across all three quantities: **2.5%**. + +### Refitting with the full dataset changes nothing + +Refitting on all 69 clean points (13 W levels, 8 L levels) gives +`f = 23872·W^-1.0729·L^-0.9285`, improving RMS from 2.07% to 2.03% — 0.04 +points. The published coefficients stand. + +> **Methodological warning.** A 1-D slice at fixed `W = 1.0` gave an `L` +> exponent of −0.871 against the law's −0.940, which looked like a 7.3% error. +> The global fit over 69 points gives −0.9285. An exponent measured on a slice +> is not comparable to one from a multivariate fit — the slice absorbs +> correlations between variables. + +--- + +## 6. Methodology: two costly artifacts + +Both looked physical. Both were instrumental. + +### Transient timestep + +`.tran 20n` **overestimates frequency by +41% on average and up to +193%**: the +integrator skips cycles and counts them as spikes. It also produces apparent +jitter of up to 55% that does not exist. + +Verified by re-simulating five configurations at 20/5/1 ns — all jitter +disappears at 1 ns: + +| W | L | Cm | @20 ns | @5 ns | @1 ns | +|---|---|---|---|---|---| +| 2.5 | 25 | 884 | 54.5% | 2.7% | **0.0%** | +| 1.75 | 25 | 612 | 47.1% | 2.2% | **0.0%** | +| 0.5 | 41 | 106 | 9.3% | 5.1% | **0.5%** | + +The bias is systematic and upward, so a high R² offers no protection: fits on +coarse data reached R² = 0.93–0.99 while describing an artifact. + +### Transient length + +With `tstop = 30 µs`, a neuron at 15 kHz (67 µs period) completes **no full +cycle**, and a cycle-count criterion flags it as non-oscillating. This produced +the phantom current floor. The frequency was scaling perfectly linearly the +whole time (30/40/50 nA → 89.9/119.6/149.3 kHz, k = 3.00 constant). + +**Rule for future sweeps:** step ≤ 1 ns, transient sized for ≥5 cycles at the +*expected* frequency, and jitter recorded as a CSV column with a `NOCONV` flag +above 2%. + +### Parallelism does not help + +`bench_par.sh` measured that ngspice already uses 7.3 of 8 cores with a single +process. Two processes in parallel push each simulation from ~100 s to >660 s +through cache contention. **Run sweeps serially.** + +--- + +## 7. Reference data + +| File | Contents | +|---|---| +| `sweep_3d_fine.csv` | 32 pts, W×L×Cm at 1 ns — the main fitting set | +| `verify_laws.csv` | 18 pts outside the fitting grid — external validation | +| `sweep_extremes.csv` | 36 pts at the edges — operating boundaries | +| `sweep_gain_isrc.csv` | 45 pts — modulation gain `k(W,L)` | +| `sweep_zsource.csv` | 28 pts — source impedance sensitivity | +| `sweep_drive_load_isrc.csv` | 24 pts — output load capability | +| `validate_feasibility.csv` | 17 pts — the (f, Vth) feasibility map | +| `sweep_iexwindow.csv`, `sweep_iexmin.csv` | current window | +| `sweep_3d_wlcm.csv` | 32 pts at 20 ns — **biased**, kept as evidence | + +Scripts that produced them: [`../tb/scripts/`](../tb/scripts/). +Design system that consumes them: [`../design/`](../design/). + +--- + +## 8. Not characterized + +- **Power consumption.** Not a design variable for now. +- **Process corners and temperature.** Everything here is typical at 27 °C. + Marked as future work. +- **Inverter sizing (M1–M4).** Fixed at PDK minimum; mapping them would add + another degree of freedom. +- **Resistive output load.** The STDP synapse presents MOS gates (capacitive + only), so this was not needed — but it would matter for a different load. + +### Open lead: `C_in` also depends on the swing + +`C_in = 0.945 + 0.865·W` has structured residuals. At fixed geometry, sweeping +`Cm` moves `C_in` — but the exponent is `+0.098` at `W = 0.5` and `−0.023` at +`W = 2.5`. **Opposite signs**, so no separable `C_in = a(1+bW)·Cm^c` exists. + +Sorting every point by membrane swing instead of by geometry collapses them +onto one curve. With `g = C_in − 0.865·W`: + +| swing [V] | 0.141 | 0.242 | 0.425 | 0.463 | 0.486 | 0.587 | 0.720 | 0.797 | 0.976 | 1.282 | +|---|---|---|---|---|---|---|---|---|---|---| +| `g` | 1.188 | **0.818** | 1.018 | 1.030 | 0.968 | 1.010 | 0.977 | 0.957 | 0.918 | 0.878 | +| `W` | 0.5 | 2.5 | 0.5 | 0.22 | 2.5 | 0.22 | 0.35 | 0.35 | 2.5 | 0.5 | + +Nine of ten points, from four `W` values spanning 11×, lie on a single +monotone curve — more swing, less `C_in`. It predicted two fresh points to +0.1% and 0.2%. The mechanism fits: `g` is the M1/M2 gate capacitance, strongly +voltage-dependent, averaged over the window the membrane traverses — and the +swing *is* that window. Since `swing = 4.114·W^0.951·L^1.065·Cm^-1.006`, what +looked like separate `Cm` and `L` dependences are one variable seen twice. + +The bold point (`W = 2.5`, `Cm = 1200`) misses by 26% and is unexplained. Its +voltage window is nearly identical to a point that fits, so window position +does not account for it. + +**Deliberately not modelled.** The residual reaches 15% of `C_in` in the +large-`Cm`/small-`W` corner, but `C_in` only carries weight when `Cm` is small, +and there the law is accurate. The product stays under **0.15% of frequency** +across the entire space the solver reaches — eight times below the frequency +law's own 1.23% RMS. Refining a correction far below the error of what it +corrects buys nothing. + +Worth reopening if M1/M2 are ever sized (the 0.945 term *is* those gates, so +the law would need refitting, not refining), if `CM_FLOOR` drops well below +50 fF, or to publish the mechanism. diff --git a/sch/lif/results/sweep_3d_fine.csv b/sch/lif/results/sweep_3d_fine.csv new file mode 100644 index 0000000..5593475 --- /dev/null +++ b/sch/lif/results/sweep_3d_fine.csv @@ -0,0 +1,33 @@ +W_M5,L_M5,Cm_f,Cm_min_pred,Iex_nA,freq_kHz,jitter_pct,n_cyc,Vth_V,Vm_min,swing_V,estado +0.5,25,54,42,100.5,2631.9,1.47,262,1.753,0.570,1.183,OK +0.5,25,75,42,100.5,2629.2,1.85,261,1.624,0.764,0.860,OK +0.5,25,109,42,100.5,2516.8,0.59,250,1.518,0.929,0.589,OK +0.5,25,168,42,100.5,2523.3,0.26,251,1.436,1.053,0.382,OK +0.5,41,76,59,100.5,1584.8,0.46,157,1.885,0.486,1.399,OK +0.5,41,106,59,100.5,1584.3,0.45,157,1.718,0.711,1.007,OK +0.5,41,153,59,100.5,1598.2,0.29,158,1.587,0.890,0.697,OK +0.5,41,236,59,100.5,1633.0,0.08,162,1.482,1.033,0.449,OK +1.0,25,111,86,100.5,1196.8,0.97,118,1.660,0.541,1.119,OK +1.0,25,154,86,100.5,1181.7,0.58,117,1.554,0.740,0.814,OK +1.0,25,223,86,100.5,1170.4,0.32,115,1.469,0.905,0.564,OK +1.0,25,344,86,100.5,1166.7,0.14,115,1.401,1.037,0.364,OK +1.0,41,157,121,100.5,763.1,0.16,75,1.790,0.469,1.322,OK +1.0,41,217,121,100.5,761.8,0.09,75,1.650,0.691,0.959,OK +1.0,41,314,121,100.5,763.8,0.03,75,1.537,0.875,0.663,OK +1.0,41,484,121,100.5,772.1,0.04,76,1.448,1.020,0.427,OK +1.75,25,198,153,100.5,654.5,0.40,64,1.585,0.520,1.065,OK +1.75,25,275,153,100.5,646.3,0.26,63,1.498,0.726,0.772,OK +1.75,25,397,153,100.5,641.7,0.12,63,1.429,0.894,0.535,OK +1.75,25,612,153,100.5,645.8,0.04,63,1.374,1.032,0.342,OK +1.75,41,280,216,100.5,414.2,0.06,40,1.707,0.434,1.273,OK +1.75,41,388,216,100.5,411.6,0.02,40,1.588,0.665,0.923,OK +1.75,41,561,216,100.5,410.3,0.01,39,1.492,0.853,0.639,OK +1.75,41,864,216,100.5,411.5,0.01,40,1.417,1.004,0.412,OK +2.5,25,287,221,100.5,463.6,0.23,45,1.538,0.538,1.000,OK +2.5,25,397,221,100.5,459.9,0.15,44,1.465,0.739,0.725,OK +2.5,25,574,221,100.5,460.3,0.06,44,1.405,0.907,0.498,OK +2.5,25,884,221,100.5,473.1,0.02,46,1.357,1.045,0.312,OK +2.5,41,405,312,100.5,283.7,0.03,27,1.652,0.422,1.230,OK +2.5,41,561,312,100.5,281.4,0.02,27,1.547,0.655,0.892,OK +2.5,41,811,312,100.5,280.1,0.01,26,1.463,0.846,0.617,OK +2.5,41,1248,312,100.5,281.1,0.01,27,1.397,0.999,0.397,OK diff --git a/sch/lif/results/sweep_3d_wlcm.csv b/sch/lif/results/sweep_3d_wlcm.csv new file mode 100644 index 0000000..d5cad0b --- /dev/null +++ b/sch/lif/results/sweep_3d_wlcm.csv @@ -0,0 +1,33 @@ +W_M5,L_M5,Cm_f,Cm_min_pred,Iex_nA,freq_kHz,Vth_V,Vm_min,estado +0.5,25,54,42,100.5,2860.5,1.767,0.352,OK +0.5,25,75,42,100.5,2862.2,1.633,0.635,OK +0.5,25,109,42,100.5,3021.2,1.520,0.852,OK +0.5,25,168,42,100.5,3085.0,1.437,1.023,OK +0.5,41,76,59,100.5,1620.3,1.890,0.477,OK +0.5,41,106,59,100.5,1658.5,1.717,0.497,OK +0.5,41,153,59,100.5,1827.0,1.589,0.828,OK +0.5,41,236,59,100.5,1846.8,1.485,0.985,OK +1.0,25,111,86,100.5,1502.9,1.661,0.354,OK +1.0,25,154,86,100.5,1538.6,1.555,0.629,OK +1.0,25,223,86,100.5,1658.8,1.468,0.854,OK +1.0,25,344,86,100.5,1775.6,1.398,1.008,OK +1.0,41,157,121,100.5,982.5,1.793,0.210,OK +1.0,41,217,121,100.5,984.7,1.651,0.685,OK +1.0,41,314,121,100.5,974.0,1.538,0.784,OK +1.0,41,484,121,100.5,1079.2,1.448,0.965,OK +1.75,25,198,153,100.5,899.6,1.578,0.468,OK +1.75,25,275,153,100.5,1051.4,1.491,0.718,OK +1.75,25,397,153,100.5,1196.9,1.425,0.897,OK +1.75,25,612,153,100.5,1611.2,1.364,1.035,OK +1.75,41,280,216,100.5,416.1,1.704,0.205,OK +1.75,41,388,216,100.5,504.9,1.586,0.527,OK +1.75,41,561,216,100.5,582.6,1.487,0.783,OK +1.75,41,864,216,100.5,543.1,1.414,0.962,OK +2.5,25,287,221,100.5,772.0,1.524,0.508,OK +2.5,25,397,221,100.5,829.0,1.457,0.722,OK +2.5,25,574,221,100.5,1109.8,1.395,0.909,OK +2.5,25,884,221,100.5,1385.2,1.342,1.069,OK +2.5,41,405,312,100.5,283.8,1.643,0.241,OK +2.5,41,561,312,100.5,300.4,1.541,0.613,OK +2.5,41,811,312,100.5,312.0,1.451,0.807,OK +2.5,41,1248,312,100.5,292.6,1.388,0.995,OK diff --git a/sch/lif/results/sweep_cm_robust.csv b/sch/lif/results/sweep_cm_robust.csv new file mode 100644 index 0000000..74d8dad --- /dev/null +++ b/sch/lif/results/sweep_cm_robust.csv @@ -0,0 +1,8 @@ +Cm_f,freq_kHz,jitter_pct,n_cyc,Vth_V +25,1302.2,3.2,126,3.771 +50,831.9,2.8,80,3.555 +75,599.0,4.0,58,2.890 +100,523.8,7.6,50,2.488 +150,545.3,6.8,53,2.088 +200,554.5,9.2,54,1.882 +300,564.5,10.6,54,1.686 \ No newline at end of file diff --git a/sch/lif/results/sweep_drive.csv b/sch/lif/results/sweep_drive.csv new file mode 100644 index 0000000..50bcca0 --- /dev/null +++ b/sch/lif/results/sweep_drive.csv @@ -0,0 +1,7 @@ +W_out_um,i_pullup_uA,i_pulldown_uA +0.22,23.7,0.0 +0.5,32.83,0.0 +1.0,84.2,0.0 +2.0,169.97,0.0 +4.0,343.75,0.0 +8.0,686.87,0.0 diff --git a/sch/lif/results/sweep_drive_load_isrc.csv b/sch/lif/results/sweep_drive_load_isrc.csv new file mode 100644 index 0000000..62a35c7 --- /dev/null +++ b/sch/lif/results/sweep_drive_load_isrc.csv @@ -0,0 +1,25 @@ +W_M7M8,C_load_f,freq_kHz,jitter_pct,tr_ns,tf_ns,vhigh,vlow,swing_out,estado +0.22,0,500.8,0.13,14.00,1.00,3.302,-0.015,3.317,OK +0.22,10,501.6,0.10,34.00,1.76,3.384,-0.182,3.566,OK +0.22,25,502.0,0.05,21.00,2.00,3.302,-0.082,3.384,OK +0.22,50,502.0,0.08,15.00,2.72,3.300,-0.000,3.300,OK +0.22,100,502.1,0.03,17.00,4.00,3.300,-0.000,3.300,OK +0.22,200,501.8,0.04,20.00,5.50,3.298,-0.000,3.298,OK +0.22,400,501.6,0.03,27.00,9.87,3.175,-0.000,3.175,OK +0.5,0,496.5,0.06,17.00,2.00,3.304,-0.014,3.318,OK +0.5,25,498.1,0.03,36.00,2.72,3.360,-0.181,3.540,OK +0.5,100,498.3,0.04,17.00,2.83,3.300,-0.000,3.300,OK +0.5,200,498.2,0.04,19.00,4.00,3.300,-0.000,3.300,OK +0.5,400,498.4,0.03,23.00,6.21,3.296,-0.000,3.296,OK +0.5,800,498.8,0.03,30.00,10.84,3.139,-0.000,3.139,OK +1.0,0,490.5,0.02,17.00,2.00,3.305,-0.012,3.317,OK +1.0,50,492.6,0.09,35.00,2.75,3.343,-0.170,3.513,OK +1.0,200,493.5,0.03,17.00,2.81,3.300,-0.000,3.300,OK +1.0,400,493.4,0.06,19.00,4.00,3.300,-0.000,3.300,OK +1.0,800,493.8,0.02,23.00,6.02,3.296,-0.000,3.296,OK +1.0,1600,493.8,0.00,31.00,11.00,3.132,-0.000,3.132,OK +2.0,0,481.8,0.07,18.00,2.00,3.303,-0.010,3.312,OK +2.0,100,483.6,0.02,28.00,2.00,3.316,-0.119,3.435,OK +2.0,400,484.9,0.02,18.00,2.83,3.300,-0.000,3.300,OK +2.0,800,485.0,0.00,19.00,4.00,3.300,-0.000,3.300,OK +2.0,1600,485.6,0.00,23.00,7.00,3.297,-0.000,3.297,OK diff --git a/sch/lif/results/sweep_extremes.csv b/sch/lif/results/sweep_extremes.csv new file mode 100644 index 0000000..c9c2284 --- /dev/null +++ b/sch/lif/results/sweep_extremes.csv @@ -0,0 +1,37 @@ +bloque,prioridad,W_M5,L_M5,Cm_f,Vin,Iex_nA,freq_kHz,jitter_pct,n_cyc,Vth_V,Vm_min,swing_V,estado +P1_L,1,1.0,20,200,1.8,100.48,1408.7,0.85,41,1.434,0.931,0.503,OK +P1_L,1,1.0,25,200,1.8,100.49,1172.6,0.40,34,1.491,0.863,0.628,OK +P1_L,1,1.0,30,200,1.8,100.49,1003.8,0.24,29,1.549,0.794,0.756,OK +P1_L,1,1.0,35,200,1.8,100.49,877.6,0.15,25,1.609,0.725,0.884,OK +P1_L,1,1.0,41,200,1.8,100.50,761.6,0.09,21,1.681,0.642,1.039,OK +P1_L,1,1.0,45,200,1.8,100.50,699.4,0.10,19,1.730,0.586,1.145,OK +P1_L,1,1.0,50,200,1.8,100.50,634.7,0.06,17,1.792,0.516,1.276,OK +P1_L,1,1.0,60,200,1.8,ERR,,,,,,,ERR +P2_Wsat,2,2.5,41,400,1.8,100.51,283.7,0.03,7,1.657,0.412,1.245,OK +P2_Wsat,2,3.0,41,400,1.8,100.51,237.7,0.02,6,1.703,0.243,1.459,OK +P2_Wsat,2,3.5,41,400,1.8,100.52,206.5,0.04,5,1.744,0.087,1.658,OK +P2_Wsat,2,4.0,41,400,1.8,100.52,184.0,0.01,4,1.783,-0.058,1.841,ANOMALO +P2_Wsat,2,5.0,41,400,1.8,100.54,154.4,0.01,3,1.853,-0.303,2.156,ANOMALO +P3_Cmin,3,1.0,41,30,1.8,100.48,1251.5,0.05,36,3.658,-0.816,4.474,ANOMALO +P3_Cmin,3,1.0,41,40,1.8,100.30,1015.1,0.05,29,3.190,-0.764,3.954,ANOMALO +P3_Cmin,3,1.0,41,50,1.8,100.54,870.3,0.26,25,2.852,-0.670,3.522,ANOMALO +P3_Cmin,3,1.0,41,60,1.8,100.54,806.0,0.22,23,2.598,-0.559,3.157,ANOMALO +P3_Cmin,3,1.0,41,80,1.8,100.53,779.2,0.56,22,2.274,-0.272,2.546,ANOMALO +P3_Cmin,3,1.0,41,100,1.8,100.52,769.6,0.41,22,2.078,0.021,2.056,OK +P4_Iex,4,1.25,50,150,0.8,530.44,2457.7,0.11,72,2.128,0.009,2.119,OK +P4_Iex,4,1.25,50,150,1.0,417.56,1975.3,0.09,58,2.117,0.009,2.108,OK +P4_Iex,4,1.25,50,150,1.1,366.12,1747.5,0.09,51,2.114,0.004,2.109,OK +P4_Iex,4,1.25,50,150,2.3,12.50,63.7,0.00,1,1.139,0.317,0.822,NO_OSCILA +P4_Iex,4,1.25,50,150,2.4,5.19,0.0,0.00,0,0.998,0.657,0.341,NO_OSCILA +P4_Iex,4,1.25,50,150,2.5,1.38,0.0,0.00,0,0.265,0.174,0.091,NO_OSCILA +P5_esq,5,0.5,60,300,1.8,ERR,,,,,,,ERR +P5_esq,5,0.5,20,60,1.8,100.49,3056.6,0.95,90,1.595,0.754,0.841,OK +P5_esq,5,2.5,20,200,1.8,100.50,594.8,0.37,16,1.547,0.454,1.093,OK +P5_esq,5,3.0,60,800,1.8,ERR,,,,,,,ERR +P5_esq,5,0.22,41,100,1.8,100.48,3737.5,0.42,111,1.517,1.036,0.481,OK +P5_esq,5,0.22,25,60,1.8,100.48,0.0,0.00,0,1.506,1.019,0.487,NO_OSCILA +P6_Wmin,6,0.22,33,150,1.8,100.47,4868.1,0.42,145,1.408,1.155,0.253,OK +P6_Wmin,6,0.3,33,150,1.8,100.48,3439.3,0.21,102,1.444,1.099,0.344,OK +P6_Wmin,6,0.4,33,150,1.8,100.48,2502.5,0.29,73,1.485,1.027,0.458,OK +P6_Wmin,6,0.5,33,150,1.8,100.48,1961.3,0.26,57,1.524,0.954,0.569,OK +P6_Wmin,6,0.6,33,150,1.8,100.49,1607.0,0.41,47,1.559,0.880,0.680,OK diff --git a/sch/lif/results/sweep_f0.csv b/sch/lif/results/sweep_f0.csv new file mode 100644 index 0000000..489555c --- /dev/null +++ b/sch/lif/results/sweep_f0.csv @@ -0,0 +1,3 @@ +W_M5,L_M5,Cm_f,WL_um2,Iex_nA,freq_kHz,jitter_pct,n_cyc,Vth_V,Vm_min,swing_V,estado +0.5,41,150,20.5,5,82.60,0.29,11,1.587,0.879,0.708,OK +0.5,41,150,20.5,10,164.63,0.26,23,1.588,0.879,0.709,OK diff --git a/sch/lif/results/sweep_gain_isrc.csv b/sch/lif/results/sweep_gain_isrc.csv new file mode 100644 index 0000000..58f0042 --- /dev/null +++ b/sch/lif/results/sweep_gain_isrc.csv @@ -0,0 +1,46 @@ +W_M5,L_M5,Cm_f,Iex_nA,freq_kHz,jitter_pct,n_cyc,Vth_V,Vm_min,swing_V,estado +0.5,25,60,25,661.6,1.00,18,1.701,0.658,1.043,OK +0.5,25,60,60,1583.7,1.01,46,1.704,0.666,1.039,OK +0.5,25,60,100,2629.5,0.97,77,1.708,0.656,1.052,OK +0.5,25,60,200,4840.2,1.31,15,1.715,0.637,1.078,OK +0.5,25,60,400,0.0,0.00,0,1.728,0.651,1.077,NO_OSCILA +0.5,41,150,25,409.4,0.29,11,1.589,0.879,0.709,OK +0.5,41,150,60,968.8,0.28,28,1.591,0.880,0.711,OK +0.5,41,150,100,1589.6,0.30,46,1.593,0.881,0.712,OK +0.5,41,150,200,3061.4,0.27,90,1.599,0.883,0.715,OK +0.5,41,150,400,0.0,0.00,0,1.608,0.888,0.720,NO_OSCILA +0.5,50,200,25,342.6,0.06,9,1.572,0.922,0.650,OK +0.5,50,200,60,809.2,0.10,23,1.574,0.923,0.652,OK +0.5,50,200,100,1324.7,0.07,38,1.576,0.923,0.653,OK +0.5,50,200,200,2531.1,0.19,74,1.581,0.926,0.655,OK +0.5,50,200,400,4625.4,0.14,137,1.591,0.930,0.661,OK +1.0,25,100,25,301.9,0.77,8,1.694,0.467,1.228,OK +1.0,25,100,60,722.3,1.01,20,1.699,0.463,1.237,OK +1.0,25,100,100,1194.7,1.01,34,1.702,0.463,1.238,OK +1.0,25,100,200,2355.8,1.16,69,1.709,0.464,1.245,OK +1.0,25,100,400,4588.7,1.19,136,1.721,0.468,1.253,OK +1.0,41,200,25,192.6,0.09,4,1.677,0.639,1.037,OK +1.0,41,200,60,458.7,0.11,12,1.679,0.640,1.039,OK +1.0,41,200,100,758.2,0.07,21,1.682,0.641,1.041,OK +1.0,41,200,200,1484.9,0.15,43,1.688,0.643,1.045,OK +1.0,41,200,400,2856.1,0.13,84,1.699,0.647,1.051,OK +1.0,50,300,25,161.5,0.02,3,1.618,0.770,0.848,OK +1.0,50,300,60,384.2,0.02,10,1.621,0.771,0.850,OK +1.0,50,300,100,634.1,0.04,18,1.624,0.772,0.852,OK +1.0,50,300,200,1237.7,0.02,36,1.629,0.774,0.855,OK +1.0,50,300,400,2362.1,0.12,69,1.638,0.778,0.861,OK +2.0,25,200,25,144.5,0.47,3,1.603,0.423,1.180,OK +2.0,25,200,60,345.0,0.39,9,1.605,0.427,1.178,OK +2.0,25,200,100,571.9,0.42,16,1.608,0.423,1.184,OK +2.0,25,200,200,1130.9,0.42,32,1.613,0.426,1.188,OK +2.0,25,200,400,2221.8,0.12,65,1.623,0.432,1.191,OK +2.0,41,350,25,90.0,0.00,1,1.648,0.494,1.154,NO_OSCILA +2.0,41,350,60,214.8,0.04,5,1.651,0.494,1.157,OK +2.0,41,350,100,356.2,0.04,9,1.653,0.495,1.159,OK +2.0,41,350,200,702.7,0.05,19,1.659,0.496,1.163,OK +2.0,41,350,400,1371.7,0.02,40,1.669,0.499,1.170,OK +2.0,50,500,25,75.0,0.00,1,1.616,0.618,0.998,NO_OSCILA +2.0,50,500,60,178.8,0.01,4,1.618,0.618,1.000,OK +2.0,50,500,100,296.2,0.01,7,1.620,0.619,1.001,OK +2.0,50,500,200,583.8,0.02,16,1.626,0.621,1.005,OK +2.0,50,500,400,1135.9,0.04,32,1.635,0.623,1.011,OK diff --git a/sch/lif/results/sweep_iex_robust.csv b/sch/lif/results/sweep_iex_robust.csv new file mode 100644 index 0000000..a30f362 --- /dev/null +++ b/sch/lif/results/sweep_iex_robust.csv @@ -0,0 +1,12 @@ +Vin_V,Iex_nA,freq_kHz,jitter_pct,n_cyc,Vth_V +1.40,232.0,1203.7,5.3,117,2.106 +1.50,194.0,1022.2,5.1,100,2.091 +1.60,159.5,856.7,5.6,83,2.092 +1.70,128.3,680.6,4.6,65,2.090 +1.80,100.5,545.3,6.8,53,2.088 +1.90,76.1,427.0,9.1,41,2.085 +2.00,55.1,303.7,7.4,29,2.077 +2.10,37.5,207.6,7.6,19,2.083 +2.20,23.3,127.3,7.9,11,2.073 +2.30,12.5,66.2,1.7,6,2.078 +2.40,5.2,28.5,0.0,1,2.063 \ No newline at end of file diff --git a/sch/lif/results/sweep_iexmin.csv b/sch/lif/results/sweep_iexmin.csv new file mode 100644 index 0000000..ea23442 --- /dev/null +++ b/sch/lif/results/sweep_iexmin.csv @@ -0,0 +1,10 @@ +W_M5,L_M5,Cm_f,WL_um2,Iex_nA,freq_kHz,jitter_pct,n_cyc,Vth_V,Vm_min,swing_V,estado +2.0,50,500,100.0,5,15.0,0.01,2,1.614,0.618,0.997,OK +2.0,50,500,100.0,8,24.1,0.00,3,1.615,0.618,0.997,OK +2.0,50,500,100.0,12,36.1,0.01,6,1.615,0.618,0.997,OK +2.0,50,500,100.0,16,48.0,0.00,8,1.615,0.618,0.997,OK +2.0,50,500,100.0,20,60.0,0.01,10,1.616,0.618,0.998,OK +2.0,50,500,100.0,25,74.9,0.01,13,1.616,0.618,0.998,OK +2.0,50,500,100.0,30,89.9,0.01,16,1.616,0.618,0.998,OK +2.0,41,350,82.0,4,14.5,0.00,1,1.646,0.493,1.153,NO_OSCILA +2.0,41,350,82.0,7,25.3,0.03,4,1.647,0.493,1.153,OK diff --git a/sch/lif/results/sweep_iexwindow.csv b/sch/lif/results/sweep_iexwindow.csv new file mode 100644 index 0000000..9c41954 --- /dev/null +++ b/sch/lif/results/sweep_iexwindow.csv @@ -0,0 +1,48 @@ +tipo,W_M5,L_M5,Cm_f,Iex_nA,freq_kHz,jitter_pct,n_cyc,Vth_V,Vm_min,swing_V,estado +MAX,0.5,25,60,250,0.0,0.00,0,1.719,0.637,1.082,NO_OSCILA +MAX,0.5,25,60,300,0.0,0.00,0,1.721,0.650,1.071,NO_OSCILA +MAX,0.5,25,60,350,0.0,0.00,0,1.724,0.650,1.074,NO_OSCILA +MAX,0.5,25,60,400,0.0,0.00,0,1.728,0.651,1.077,NO_OSCILA +MAX,0.5,25,60,500,0.0,0.00,0,1.731,0.671,1.060,NO_OSCILA +MAX,0.5,25,60,700,0.0,0.00,0,1.747,0.661,1.085,NO_OSCILA +MAX,0.5,41,150,250,3756.8,0.38,111,1.601,0.884,0.717,OK +MAX,0.5,41,150,300,4420.9,0.20,131,1.604,0.886,0.718,OK +MAX,0.5,41,150,350,0.0,0.00,0,1.606,0.887,0.719,NO_OSCILA +MAX,0.5,41,150,400,0.0,0.00,0,1.608,0.888,0.720,NO_OSCILA +MAX,0.5,41,150,500,0.0,0.00,0,1.613,0.890,0.722,NO_OSCILA +MAX,0.5,41,150,700,0.0,0.00,0,1.621,0.894,0.727,NO_OSCILA +MAX,0.5,50,200,400,4625.4,0.14,137,1.591,0.930,0.661,OK +MAX,0.5,50,200,500,0.0,0.00,0,1.595,0.932,0.663,NO_OSCILA +MAX,0.5,50,200,600,0.0,0.00,0,1.599,0.935,0.664,NO_OSCILA +MAX,0.5,50,200,800,0.0,0.00,0,1.607,0.938,0.669,NO_OSCILA +MAX,0.5,50,200,1000,0.0,0.00,0,1.615,0.943,0.673,NO_OSCILA +MAX,1.0,25,100,400,4588.7,1.19,136,1.721,0.468,1.253,OK +MAX,1.0,25,100,600,0.0,0.00,0,1.732,0.475,1.258,NO_OSCILA +MAX,1.0,25,100,800,0.0,0.00,0,1.743,0.482,1.261,NO_OSCILA +MAX,1.0,25,100,1000,0.0,0.00,0,1.753,0.489,1.264,NO_OSCILA +MAX,1.0,25,100,1400,0.0,0.00,0,1.774,0.501,1.272,NO_OSCILA +MAX,1.0,41,200,400,2856.1,0.13,84,1.699,0.647,1.051,OK +MAX,1.0,41,200,600,4116.9,0.12,122,1.709,0.652,1.057,OK +MAX,1.0,41,200,800,0.0,0.00,0,1.719,0.656,1.063,NO_OSCILA +MAX,1.0,41,200,1200,0.0,0.00,0,1.738,0.664,1.074,NO_OSCILA +MAX,1.0,41,200,1600,0.0,0.00,0,1.757,0.674,1.083,NO_OSCILA +MIN,2.0,50,500,10,0.0,0.00,0,1.204,1.005,0.199,NO_OSCILA +MIN,2.0,50,500,15,45.1,0.00,1,1.615,0.618,0.997,NO_OSCILA +MIN,2.0,50,500,20,60.0,0.00,1,1.131,0.733,0.398,NO_OSCILA +MIN,2.0,50,500,25,75.0,0.00,1,1.616,0.618,0.998,NO_OSCILA +MIN,2.0,50,500,30,89.9,0.00,1,1.616,0.618,0.998,NO_OSCILA +MIN,2.0,50,500,40,119.6,0.01,2,1.617,0.618,0.999,NO_OSCILA +MIN,2.0,50,500,50,149.3,0.01,3,1.617,0.618,0.999,OK +MIN,2.0,41,350,10,36.1,0.00,1,1.647,0.494,1.153,NO_OSCILA +MIN,2.0,41,350,15,54.2,0.00,1,0.982,0.557,0.425,NO_OSCILA +MIN,2.0,41,350,20,72.1,0.00,1,1.647,0.493,1.154,NO_OSCILA +MIN,2.0,41,350,25,90.0,0.00,1,1.648,0.494,1.154,NO_OSCILA +MIN,2.0,41,350,30,108.0,0.01,2,1.649,0.494,1.154,NO_OSCILA +MIN,2.0,41,350,40,143.7,0.03,3,1.649,0.494,1.156,OK +MIN,2.0,41,350,50,179.3,0.01,4,1.650,0.494,1.156,OK +MIN,2.0,25,200,5,0.0,0.00,0,1.166,0.920,0.247,NO_OSCILA +MIN,2.0,25,200,8,46.4,0.00,1,1.599,0.429,1.170,NO_OSCILA +MIN,2.0,25,200,12,69.3,0.00,1,1.602,0.424,1.178,NO_OSCILA +MIN,2.0,25,200,16,92.8,0.00,1,1.601,0.425,1.175,NO_OSCILA +MIN,2.0,25,200,20,116.5,0.00,2,1.601,0.425,1.176,NO_OSCILA +MIN,2.0,25,200,25,144.5,0.47,3,1.603,0.423,1.180,OK diff --git a/sch/lif/results/sweep_lm5_robust.csv b/sch/lif/results/sweep_lm5_robust.csv new file mode 100644 index 0000000..f7105d6 --- /dev/null +++ b/sch/lif/results/sweep_lm5_robust.csv @@ -0,0 +1,7 @@ +L_M5_um,freq_kHz,jitter_pct,n_cyc,Vth_lif_V +25,1280.2,20.9,126,1.608 +30,965.3,12.6,95,1.695 +35,863.1,8.2,84,1.791 +40,808.4,23.4,79,1.884 +45,521.7,19.8,50,1.988 +50,545.3,6.8,53,2.088 \ No newline at end of file diff --git a/sch/lif/results/sweep_wl_m5_2d.csv b/sch/lif/results/sweep_wl_m5_2d.csv new file mode 100644 index 0000000..1b34b42 --- /dev/null +++ b/sch/lif/results/sweep_wl_m5_2d.csv @@ -0,0 +1,17 @@ +W_M5,L_M5,area_WL,Iex_nA,freq_kHz,Vth_V,Vm_min,estado +0.5,25u,12.5,100.5,3037.5,1.413,1.075,OK +0.5,33u,16.5,100.5,2021.7,1.467,0.975,OK +0.5,41u,20.5,100.5,1889.4,1.520,0.897,OK +0.5,50u,25.0,100.5,1257.1,1.583,0.861,OK +1.0,25u,25.0,100.5,1608.8,1.491,0.788,OK +1.0,33u,33.0,100.5,972.2,1.585,0.723,OK +1.0,41u,41.0,100.5,957.5,1.683,0.535,OK +1.0,50u,50.0,100.5,694.9,1.793,0.498,OK +1.75,25u,43.8,100.5,906.7,1.577,0.451,OK +1.75,33u,57.8,100.5,623.1,1.722,0.267,OK +1.75,41u,71.8,100.5,390.3,1.875,-0.248,ANOMALO +1.75,50u,87.5,100.5,383.9,2.055,-0.105,ANOMALO +2.5,25u,62.5,100.5,713.8,1.638,0.142,OK +2.5,33u,82.5,100.5,479.0,1.832,-0.220,ANOMALO +2.5,41u,102.5,100.5,273.6,2.016,-0.665,ANOMALO +2.5,50u,125.0,100.5,258.9,2.260,-0.666,ANOMALO diff --git a/sch/lif/results/sweep_wm5.csv b/sch/lif/results/sweep_wm5.csv new file mode 100644 index 0000000..fd0830a --- /dev/null +++ b/sch/lif/results/sweep_wm5.csv @@ -0,0 +1,7 @@ +W_M5,L_M5,ratio_WL,Iex_nA,freq_kHz,Vm_min,Vth_V,estado +0.5,50u,0.0100,100.5,1358.8,0.781,1.680,OK +1.25,50u,0.0250,100.5,545.3,-0.050,2.088,OK +2.5,50u,0.0500,100.5,307.5,-0.745,2.560,ANOMALO +5.0,50u,0.1000,100.3,278.4,-0.789,3.251,ANOMALO +0.625,25u,0.0250,100.5,2530.9,0.925,1.485,OK +2.5,100u,,ERR,,,, diff --git a/sch/lif/results/sweep_zsource.csv b/sch/lif/results/sweep_zsource.csv new file mode 100644 index 0000000..c723c12 --- /dev/null +++ b/sch/lif/results/sweep_zsource.csv @@ -0,0 +1,29 @@ +W_M5,L_M5,Cm_f,Iex_nA,ro_ohm,ro_label,freq_kHz,jitter_pct,n_cyc,Vth_V,Vm_min,swing_V,estado +1.0,41,200,100,1e12,inf,758.2,0.07,21,1.682,0.641,1.041,OK +1.0,41,200,100,1e9,1G,775.2,0.10,22,1.682,0.641,1.041,OK +1.0,41,200,100,1e8,100M,930.5,0.09,26,1.683,0.641,1.042,OK +1.0,41,200,100,3e7,30M,1325.9,0.10,38,1.686,0.643,1.043,OK +1.0,41,200,100,1e7,10M,2404.6,0.07,71,1.692,0.646,1.046,OK +1.0,41,200,100,3e6,3M,0.0,0.00,0,1.714,0.659,1.055,NO_OSCILA +1.0,41,200,100,1e6,1M,0.0,0.00,0,1.768,0.693,1.075,NO_OSCILA +0.5,25,60,100,1e12,inf,2629.5,0.97,77,1.708,0.656,1.052,OK +0.5,25,60,100,1e9,1G,2684.9,1.40,79,1.709,0.672,1.036,OK +0.5,25,60,100,1e8,100M,3233.1,1.23,95,1.710,0.634,1.075,OK +0.5,25,60,100,3e7,30M,4567.6,2.75,135,1.713,0.635,1.078,NOCONV +0.5,25,60,100,1e7,10M,0.0,0.00,0,1.721,0.642,1.079,NO_OSCILA +0.5,25,60,100,3e6,3M,0.0,0.00,0,1.747,0.684,1.063,NO_OSCILA +0.5,25,60,100,1e6,1M,0.0,0.00,0,1.812,0.725,1.088,NO_OSCILA +2.0,50,500,100,1e12,inf,296.2,0.01,7,1.620,0.619,1.001,OK +2.0,50,500,100,1e9,1G,303.0,0.02,8,1.621,0.619,1.002,OK +2.0,50,500,100,1e8,100M,364.5,0.01,9,1.621,0.619,1.002,OK +2.0,50,500,100,3e7,30M,521.7,0.01,14,1.624,0.620,1.003,OK +2.0,50,500,100,1e7,10M,956.8,0.05,27,1.630,0.623,1.007,OK +2.0,50,500,100,3e6,3M,2342.8,0.10,69,1.648,0.631,1.017,OK +2.0,50,500,100,1e6,1M,0.0,0.00,0,1.694,0.657,1.037,NO_OSCILA +1.0,41,200,25,1e12,inf,192.6,0.09,4,1.677,0.639,1.037,OK +1.0,41,200,25,1e9,1G,210.4,0.12,5,1.677,0.640,1.037,OK +1.0,41,200,25,1e8,100M,370.5,0.12,10,1.678,0.640,1.038,OK +1.0,41,200,25,3e7,30M,777.4,0.08,22,1.681,0.641,1.040,OK +1.0,41,200,25,1e7,10M,1889.9,0.15,55,1.688,0.644,1.044,OK +1.0,41,200,25,3e6,3M,0.0,0.00,0,1.710,0.656,1.054,NO_OSCILA +1.0,41,200,25,1e6,1M,0.0,0.00,0,1.765,0.689,1.076,NO_OSCILA diff --git a/sch/lif/results/validate_feasibility.csv b/sch/lif/results/validate_feasibility.csv new file mode 100644 index 0000000..f032614 --- /dev/null +++ b/sch/lif/results/validate_feasibility.csv @@ -0,0 +1,18 @@ +f_obj,W_M5,L_M5,Cm_f,mult_cmmin,f_pred,Vth_pred,Iex_nA,freq_kHz,jitter_pct,n_cyc,Vth_V,Vm_min,swing_V,estado +300,1.97,50,293,1.05,300,1.842,100.51,305.7,0.04,8,1.857,0.185,1.672,OK +300,1.97,50,251,0.90,300,1.900,100.52,307.1,0.04,8,1.954,0.009,1.945,OK +300,1.97,50,223,0.80,300,1.955,100.53,308.6,0.06,8,2.038,-0.142,2.180,ANOMALO +300,1.97,50,195,0.70,300,2.036,100.53,310.5,0.06,8,2.148,-0.325,2.473,ANOMALO +300,1.97,50,419,1.50,300,1.665,100.50,303.3,0.02,8,1.682,0.507,1.176,OK +1000,0.65,50,93,1.05,1000,2.043,100.51,995.3,0.31,28,2.054,0.244,1.810,OK +1000,0.65,50,79,0.90,1000,2.126,100.52,995.1,0.32,28,2.189,0.065,2.124,OK +1000,0.65,50,70,0.80,1000,2.204,100.52,993.8,0.47,28,2.303,-0.088,2.392,ANOMALO +1000,0.65,50,62,0.70,1000,2.298,100.53,999.8,0.94,28,2.432,-0.259,2.691,ANOMALO +1000,0.65,50,132,1.50,1000,1.789,100.50,994.6,0.16,28,1.828,0.551,1.278,OK +3000,0.24,48,32,1.05,3000,2.521,100.50,2797.9,1.48,82,2.170,0.258,1.912,OK +3000,0.24,48,28,0.90,3000,2.652,100.51,2787.3,1.23,82,2.292,0.101,2.191,OK +3000,0.24,48,25,0.80,3000,2.784,100.51,2770.0,1.31,82,2.406,-0.046,2.452,OK +3000,0.24,48,22,0.70,3000,2.976,100.52,2772.2,0.76,81,2.557,-0.208,2.765,ANOMALO +3000,0.24,48,46,1.50,3000,2.096,100.49,2792.0,0.32,82,1.908,0.577,1.331,OK +200,2.86,50,432,1.05,200,1.809,100.52,206.0,0.01,5,1.781,0.173,1.608,OK +200,2.86,50,329,0.80,200,1.930,100.53,208.7,0.01,5,1.940,-0.151,2.091,ANOMALO diff --git a/sch/lif/results/verify_iexvin.csv b/sch/lif/results/verify_iexvin.csv new file mode 100644 index 0000000..115f491 --- /dev/null +++ b/sch/lif/results/verify_iexvin.csv @@ -0,0 +1,6 @@ +Vin,Iex_nA,freq_kHz,jitter_pct,estado +1.2,318.04,1529.8,0.10,OK +1.5,194.04,954.9,0.08,OK +1.8,100.52,503.5,0.10,OK +2.0,55.14,278.7,0.09,OK +2.2,23.31,118.4,0.03,OK diff --git a/sch/lif/results/verify_laws.csv b/sch/lif/results/verify_laws.csv new file mode 100644 index 0000000..ff103f6 --- /dev/null +++ b/sch/lif/results/verify_laws.csv @@ -0,0 +1,19 @@ +W_M5,L_M5,Cm_f,Iex_nA,freq_kHz,jitter_pct,n_cyc,Vth_V,Vm_min,swing_V,estado +0.75,33,115,100.5,1267.0,0.47,36,1.708,0.615,1.093,OK +0.75,33,192,100.5,1263.6,0.23,36,1.539,0.880,0.659,OK +0.75,33,269,100.5,1270.1,0.09,36,1.466,0.997,0.469,OK +0.75,50,154,100.5,857.4,0.14,24,1.809,0.551,1.258,OK +0.75,50,257,100.5,866.5,0.07,24,1.601,0.848,0.753,OK +0.75,50,360,100.5,878.8,0.04,25,1.511,0.976,0.535,OK +1.4,33,220,100.5,641.0,0.18,18,1.630,0.585,1.045,OK +1.4,33,367,100.5,635.7,0.05,18,1.489,0.860,0.629,OK +1.4,33,514,100.5,635.9,0.03,18,1.428,0.981,0.448,OK +1.4,50,295,100.5,441.4,0.01,12,1.729,0.530,1.199,OK +1.4,50,492,100.5,441.9,0.02,12,1.549,0.831,0.719,OK +1.4,50,689,100.5,444.4,0.02,12,1.473,0.961,0.512,OK +2.1,33,336,100.5,415.0,0.07,11,1.577,0.574,1.002,OK +2.1,33,560,100.5,411.1,0.02,11,1.456,0.852,0.603,OK +2.1,33,784,100.5,411.6,0.03,11,1.404,0.975,0.429,OK +2.1,50,448,100.5,283.0,0.00,7,1.673,0.504,1.169,OK +2.1,50,747,100.5,281.4,0.01,7,1.514,0.812,0.703,OK +2.1,50,1046,100.5,281.7,0.01,7,1.447,0.946,0.500,OK diff --git a/sch/lif/tb/.gitignore b/sch/lif/tb/.gitignore index 56ee75f..c76e43a 100644 --- a/sch/lif/tb/.gitignore +++ b/sch/lif/tb/.gitignore @@ -1 +1,8 @@ +# artefactos de simulacion (regenerables corriendo los scripts) +raws_*/ +netlists_*/ _sim_output/ +*.raw + +# netlists que genera design/verify.py al comprobar un diseño +verify_*.spice diff --git a/sch/lif/tb/README.md b/sch/lif/tb/README.md new file mode 100644 index 0000000..3fd4e11 --- /dev/null +++ b/sch/lif/tb/README.md @@ -0,0 +1,54 @@ +# LIF characterization testbenches + +Two testbenches, differing in how the cell is excited. **The current one is +`tb_charac_isrc.spice`**, since the cell takes a current input. + +| file | input | status | +|---|---|---| +| `tb_charac_isrc.spice` | `IEX` straight into the membrane node | **current** | +| `tb_charac.spice` | `Vin` driving M6 (PMOS mirror) | historical | + +Both are self-contained: the `neurona` subcircuit is embedded, so they do not +depend on the `.sch` files. Every script in `scripts/` patches them with `sed`. + +## Why two + +The cell connects to different stages, so it receives current rather than +voltage. `tb_charac_isrc.spice` reflects that and carries no M6. + +The switch was checked against the earlier characterization and **does not +invalidate it**: the same operating point gives 494 kHz with M6 and 501 kHz with +an ideal source (1.4% apart). M6's output impedance was high enough — it is a +PMOS with `L = 17 µm` — to behave nearly ideally. + +The law `Iex = 169.1·(2.571 − Vin)²` (RMS 0.07%) is still valid, but it now +describes a block that lives **outside** the cell. Kept as a reference for +whoever designs the input stage. + +## Two things to respect when simulating + +**Use `.tran 1n`, not 20n.** With a coarse step the frequency is overestimated +by **+41% on average and up to +193%**: the integrator skips cycles and counts +them as spikes. It also fabricates jitter of up to 55%. +`scripts/test_tstep.sh` demonstrates this by re-simulating at 20/5/1 ns. + +**Size the transient for ≥5 cycles.** With a fixed `tstop`, a neuron at 15 kHz +(67 µs period) completes no full cycle in 30 µs and appears not to oscillate. +That produced a "current floor" that turned out not to exist. +`scripts/test_tstop.sh` verified that shortening 100 µs → 30 µs gives identical +results to 4 significant figures **when the frequency allows it**. + +## Useful nodes in the `.raw` + +| signal | ngspice name | +|---|---| +| membrane | `v(x1.integration)` — it lives inside the subcircuit | +| spike | `v(spike)` | +| mirror current (`tb_charac` only) | `@m.x1.xm6.m0[id]` — the `.m0` suffix is BSIM4 | + +## Results + +CSV files live in [`../results/`](../results/); the consolidated equations are in +[`../results/lif_knowledge_base.md`](../results/lif_knowledge_base.md). + +The `raws_*/` directories are regenerable and gitignored (~376 MB total). diff --git a/sch/lif/tb/scripts/README.md b/sch/lif/tb/scripts/README.md new file mode 100644 index 0000000..0c59351 --- /dev/null +++ b/sch/lif/tb/scripts/README.md @@ -0,0 +1,108 @@ +# LIF characterization scripts + +Each script patches a testbench with `sed` and measures the resulting `.raw` +using **multi-cycle period averaging** — not a single-cycle `.meas`. + +Scripts named `*_isrc` use `../tb_charac_isrc.spice` (current input, the current +topology); the rest use `../tb_charac.spice` (voltage input through M6, +historical). + +> ## ⚠️ Use a 1 ns timestep, not 20 ns +> +> Scripts predating `sweep_3d_fine.sh` use `.tran 20n`, which **overestimates +> frequency by +41% on average and up to +193%**: the integrator skips cycles +> and counts them as spikes. It also fabricates jitter of up to 55%. +> +> `test_tstep.sh` demonstrates the effect (same circuit at 20/5/1 ns). New +> sweeps must use `.tran 1n` and flag any point with jitter > 2% as `NOCONV`. +> +> Cost: `.tran 1n` produces 100k points per simulation (~3.2 MB per `.raw`, +> ~20× slower). Worth it — fits go from LOO 8.5% to 3.1%. + +## Running them + +Inside the container, with the repo mounted at `/foss/repo`: + +```bash +cd /foss/repo/sch/lif/tb +bash scripts/