From 73fa15ce0d0f4bab58ef20b27d863b71370652ed Mon Sep 17 00:00:00 2001 From: Nimish Kapoor Date: Mon, 7 Sep 2026 02:08:45 +0530 Subject: [PATCH] Add ngspice simulation checks for gf180 (and sky130) with CI workflow --- .github/workflows/sim.yml | 185 +++++ tests/sim/run_cell_sim.py | 670 ++++++++++++++++++ tests/sim/testbenches/gf180/checks.json | 92 +++ .../gf180/current_mirror_nfet.spice | 29 + .../gf180/current_mirror_pfet.spice | 30 + tests/sim/testbenches/gf180/diff_pair.spice | 38 + .../testbenches/gf180/diff_pair_ibias.spice | 38 + .../gf180/flipped_voltage_follower.spice | 39 + .../gf180/low_voltage_cmirror.spice | 37 + .../testbenches/gf180/transmission_gate.spice | 36 + tests/sim/testbenches/sky130/checks.json | 35 + .../sky130/current_mirror_nfet.spice | 32 + .../sky130/current_mirror_pfet.spice | 34 + tests/sim/testbenches/sky130/diff_pair.spice | 33 + .../testbenches/sky130/diff_pair_ibias.spice | 34 + .../sky130/flipped_voltage_follower.spice | 27 + .../sky130/low_voltage_cmirror.spice | 36 + .../sky130/transmission_gate.spice | 38 + 18 files changed, 1463 insertions(+) create mode 100644 .github/workflows/sim.yml create mode 100644 tests/sim/run_cell_sim.py create mode 100644 tests/sim/testbenches/gf180/checks.json create mode 100644 tests/sim/testbenches/gf180/current_mirror_nfet.spice create mode 100644 tests/sim/testbenches/gf180/current_mirror_pfet.spice create mode 100644 tests/sim/testbenches/gf180/diff_pair.spice create mode 100644 tests/sim/testbenches/gf180/diff_pair_ibias.spice create mode 100644 tests/sim/testbenches/gf180/flipped_voltage_follower.spice create mode 100644 tests/sim/testbenches/gf180/low_voltage_cmirror.spice create mode 100644 tests/sim/testbenches/gf180/transmission_gate.spice create mode 100644 tests/sim/testbenches/sky130/checks.json create mode 100644 tests/sim/testbenches/sky130/current_mirror_nfet.spice create mode 100644 tests/sim/testbenches/sky130/current_mirror_pfet.spice create mode 100644 tests/sim/testbenches/sky130/diff_pair.spice create mode 100644 tests/sim/testbenches/sky130/diff_pair_ibias.spice create mode 100644 tests/sim/testbenches/sky130/flipped_voltage_follower.spice create mode 100644 tests/sim/testbenches/sky130/low_voltage_cmirror.spice create mode 100644 tests/sim/testbenches/sky130/transmission_gate.spice diff --git a/.github/workflows/sim.yml b/.github/workflows/sim.yml new file mode 100644 index 00000000..8aae385b --- /dev/null +++ b/.github/workflows/sim.yml @@ -0,0 +1,185 @@ +name: "Automated: Cell ngspice" + +# Triggered automatically when the DRC workflow finishes (success OR failure), +# in parallel with LVS. DRC produces the GDS + reference netlists, so the sim +# job just downloads the DRC artifact and runs ngspice on it — no need to +# rebuild the cells. +# +# Why hang off DRC (alongside LVS) instead of chaining off LVS: it reuses the +# exact trigger/artifact plumbing the LVS workflow already proves out, and +# keeps sim independent so a flaky LVS run doesn't block it. +# +# Also runnable on demand against the latest DRC artifact. +on: + workflow_run: + workflows: ["Cell DRC"] + types: [completed] + workflow_dispatch: + inputs: + drc_run_id: + description: "GitHub Actions run id of the DRC workflow whose artifacts to consume (defaults to latest successful run)." + required: false + +# Same concurrency fallback as lvs.yml: workflow_run-triggered runs report +# github.ref as the default branch, so fall back to the triggering DRC run's +# head_branch when present. +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }} + cancel-in-progress: true + +jobs: + sim: + name: ngspice (${{ matrix.pdk }}) + runs-on: ubuntu-22.04 + timeout-minutes: 30 + # Skip only cancelled DRC runs; run sim on whatever cells DRC produced, + # mirroring lvs.yml. + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion != 'cancelled' }} + + # Same permission set as lvs.yml: actions:read for cross-run + # download-artifact, checks:write for the JUnit publisher. + permissions: + contents: read + actions: read + checks: write + + container: + image: hpretl/iic-osic-tools:latest + options: --user root + env: + PDK_ROOT: /foss/pdks + DEBIAN_FRONTEND: noninteractive + PYTHONUNBUFFERED: "1" + PYTHONPATH: "" + PATH: /foss/tools/bin:/foss/tools/sak:/foss/tools/kactus2:/foss/tools/klayout:/foss/tools/osic-multitool:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + + strategy: + fail-fast: false + matrix: + # Both PDKs ship ngspice model libs, but their shapes differ: + # sky130 is a single sky130A/libs.tech/ngspice/sky130.lib.spice with + # corner sections; gf180 (ciel versioned path) needs design.ngspice + # included PLUS several .lib sections of sm141064.ngspice (MOS corner + # + cap_mim/res_typical/moscap_typical/mimcap_typical) — `typical` is + # not a section of design.ngspice. run_cell_sim.py assembles the right + # preamble per --pdk. Testbenches resolve per PDK from + # tests/sim/testbenches// (rails and probe points differ). + # + # NOTE: the gf180 leg requires the "Cell DRC" workflow to also run a + # gf180 matrix leg and upload a `drc-gf180` artifact. + pdk: [sky130, gf180] + + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - name: Download DRC artifact (drc-${{ matrix.pdk }}) + uses: actions/download-artifact@v4 + with: + name: drc-${{ matrix.pdk }} + path: drc_inputs/${{ matrix.pdk }} + # From the triggering DRC run when via workflow_run; falls back to + # the manual input or current run for workflow_dispatch. + run-id: ${{ github.event.workflow_run.id || inputs.drc_run_id || github.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache uv + CPython 3.10 + id: cache-uv + uses: actions/cache@v4 + with: + path: | + /headless/.local/bin/uv + /headless/.local/bin/uvx + /headless/.local/share/uv + key: uv-py310-${{ runner.os }}-v1 + + - name: Install Python 3.10 (uv) + run: | + set -euxo pipefail + if [ ! -x "$HOME/.local/bin/uv" ]; then + curl -LsSf https://astral.sh/uv/install.sh | sh + fi + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + uv python install 3.10 + echo "PYTHON310=$(uv python find 3.10)" >> "$GITHUB_ENV" + + - name: Show tool versions + run: | + set -euxo pipefail + ngspice --version 2>&1 | head -3 || true + "$PYTHON310" --version + ls "$PDK_ROOT" + # Surface the ngspice model libraries up front so a PDK install + # hiccup shows here rather than as a cryptic "can't find include" + # mid-sim. gf180 needs BOTH design.ngspice (params/options) and + # sm141064.ngspice (device model sections), so hard-fail if either + # is missing. + if [ "${{ matrix.pdk }}" = "sky130" ]; then + ls "$PDK_ROOT/sky130A/libs.tech/ngspice/sky130.lib.spice" + else + ver=$(cat "$PDK_ROOT/ciel/gf180mcu/current") + ls "$PDK_ROOT/ciel/gf180mcu/versions/$ver/gf180mcuD/libs.tech/ngspice/design.ngspice" \ + "$PDK_ROOT/ciel/gf180mcu/versions/$ver/gf180mcuD/libs.tech/ngspice/sm141064.ngspice" + fi + + - name: Cache python venv + id: cache-venv + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.venv + # Same key as drc.yml/lvs.yml: identical interpreter + deps, so all + # three workflows share a single restored venv. + key: drc-venv-py310-${{ runner.os }}-${{ hashFiles('setup.py', 'src/glayout/**/*.py') }}-v2 + restore-keys: | + drc-venv-py310-${{ runner.os }}- + + - name: Create venv and install glayout (cache miss) + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + set -euxo pipefail + rm -rf "$GITHUB_WORKSPACE/.venv" + "$PYTHON310" -m venv "$GITHUB_WORKSPACE/.venv" + . "$GITHUB_WORKSPACE/.venv/bin/activate" + uv pip install -e . + + # No "refresh editable install" step on cache hit — see drc.yml. + + - name: Sanity-check sim inputs + run: | + set -euxo pipefail + ls -la drc_inputs/${{ matrix.pdk }}/netlists || { echo "no netlists/ in DRC artifact"; exit 1; } + # gds/ is only needed if run_cell_sim.py does its own PEX extraction; + # a netlist-only (pre-layout) sim can proceed without it. + ls -la drc_inputs/${{ matrix.pdk }}/gds || echo "warning: no gds/ (ok for netlist-only sim)" + + - name: Run cell ngspice + run: | + set -euxo pipefail + . "$GITHUB_WORKSPACE/.venv/bin/activate" + # Testbenches are picked up from tests/sim/testbenches/${{ matrix.pdk }}/ + # automatically (flat tests/sim/testbenches/ as legacy fallback). + python tests/sim/run_cell_sim.py \ + --pdk ${{ matrix.pdk }} \ + --inputs-dir drc_inputs/${{ matrix.pdk }} \ + --out-dir sim_results/${{ matrix.pdk }} + + - name: Upload sim artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: sim-${{ matrix.pdk }} + path: sim_results/${{ matrix.pdk }} + retention-days: 14 + + - name: Publish JUnit summary + if: ${{ always() && hashFiles(format('sim_results/{0}/junit.xml', matrix.pdk)) != '' }} + uses: mikepenz/action-junit-report@v4 + with: + report_paths: sim_results/${{ matrix.pdk }}/junit.xml + check_name: ngspice report (${{ matrix.pdk }}) + require_tests: true + diff --git a/tests/sim/run_cell_sim.py b/tests/sim/run_cell_sim.py new file mode 100644 index 00000000..918874a8 --- /dev/null +++ b/tests/sim/run_cell_sim.py @@ -0,0 +1,670 @@ +"""Runs ngspice on every cell using the reference netlist emitted by +``tests/drc/run_cell_drc.py`` plus a per-cell testbench. + +The sim CI workflow pulls the DRC artifact (``drc_results//``), which +contains: + + drc_results// + gds/.gds + netlists/.spice <-- DUT subckt, written by run_cell_drc.py + reports/.lyrdb + summary.json + +A cell is simulated when it has BOTH that reference netlist AND a testbench at: + + tests/sim/testbenches//.spice + +Testbenches are per-PDK because rails, probe points, stimulus levels and pass +bands all differ between sky130's 1.8 V and gf180's 3.3 V devices. If the +per-PDK directory does not exist, the runner falls back to the flat +``tests/sim/testbenches/`` layout for backward compatibility. + +The testbench is the stimulus + analysis + `.measure` cards only. It must NOT +declare the model `.lib`/`.include` lines or `.include` the DUT netlist — this +runner injects both so the same testbench works across corners. Instantiate +the DUT with a subckt call whose name matches the `.subckt ` in the +reference netlist, e.g.: + + Vdd vdd 0 1.8 + Vin in 0 PULSE(0 1.8 1n 10p 10p 5n 10n) + X1 in out vdd 0 + .tran 10p 50n + .measure tran tphl TRIG v(in) VAL=0.9 RISE=1 TARG v(out) VAL=0.9 FALL=1 + +Optionally, a sidecar ``.../testbenches//.checks.json`` gives pass +bands for measurements; without it a cell passes when ngspice finishes with no +fatal error and no failed `.measure`: + + { "tphl": {"max": 2e-9}, "tplh": {"max": 2e-9} } + +This mirrors the LVS runner's shape so the same workflow plumbing (artifact +upload, JUnit publication) works unchanged. + +MODEL HANDLING IS PER-PDK (see ``_model_deck_lines``). sky130 is a single +``.lib sky130.lib.spice ``. gf180 is NOT: ``typical`` is not a +section of ``design.ngspice``, and the device models live in +``sm141064.ngspice`` split across several sections, so the deck needs + + .include design.ngspice + .lib sm141064.ngspice typical (MOS corner section) + .lib sm141064.ngspice cap_mim + .lib sm141064.ngspice res_typical + .lib sm141064.ngspice moscap_typical + .lib sm141064.ngspice mimcap_typical + +which is what this runner assembles for --pdk gf180. + +By default this simulates the *reference* netlist (pre-layout / functional +check). Post-layout (PEX) sim is a documented extension point in +``_assemble_deck`` — it's left off the default path because per-cell magic +extraction in CI is slow and, for gf180, mis-extracts the substrate (same +reason the LVS runner drives the klayout deck for gf180). +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import traceback +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Default corner per PDK: names the sky130.lib.spice section for sky130 and +# the sm141064.ngspice MOS section for gf180. Resolved straight from PDK_ROOT +# rather than via a glayout pdk attribute, so this doesn't depend on an API +# the LVS path doesn't already use. Override per run with --model-lib / +# --corner if your install differs. +_DEFAULT_CORNER = {"sky130": "tt", "gf180": "typical"} + +# gf180 passive-device sections injected alongside the MOS corner section. +# These stay at their typical variants even when --corner overrides the MOS +# section (there is no res_fs etc.); skewed passives would need a custom deck. +_GF180_FIXED_SECTIONS = ("cap_mim", "res_typical", "moscap_typical", "mimcap_typical") + +# Per-PDK geometry scale injected into the deck (`.option scale=`). +# glayout reference netlists carry BARE MICRON w/l values (w=3 l=0.28). The +# sky130 model libs set `.option scale=1e-6` internally, so those netlists +# just work; the gf180 libs expect METERS and set no scale, so without this +# every MOS lands outside its model bins and ngspice dies with "could not +# find a valid modelname". Override per run with --scale. +_DEFAULT_SCALE = {"sky130": None, "gf180": "1e-6"} + + +def _deck_option_lines(pdk_name: str, scale_override: Optional[str] = None) -> List[str]: + """Deck-global `.option` lines injected ahead of the model preamble.""" + scale = _DEFAULT_SCALE[pdk_name] if scale_override is None else scale_override + if scale is None or str(scale).lower() in ("none", "off", ""): + return [] + return [ + "* glayout netlists use micron-unit w/l; map them onto the meter-unit", + "* model bins (sky130's lib sets this internally, gf180's does not).", + f".option scale={scale}", + ] + + +def _model_deck_lines( + pdk_name: str, + path_override: Optional[str] = None, + corner_override: Optional[str] = None, +) -> Tuple[List[str], str, Path]: + """Per-PDK model preamble for the assembled deck. + + Returns ``(deck_lines, corner, primary_model_path)``; the path is only + used for logging and an early existence warning. + + sky130: .lib "<...>/sky130.lib.spice" + gf180: .include "<...>/design.ngspice" (params/options) + .lib "<...>/sm141064.ngspice" (MOS devices) + .lib "<...>/sm141064.ngspice" cap_mim + .lib "<...>/sm141064.ngspice" res_typical + .lib "<...>/sm141064.ngspice" moscap_typical + .lib "<...>/sm141064.ngspice" mimcap_typical + + Paths resolve from PDK_ROOT (gf180 via ciel's ``current`` pointer, the + same pattern lvs.yml uses to locate the klayout deck). --model-lib + overrides them: for sky130 pass the .lib file itself; for gf180 pass the + ngspice models *directory* (the one holding design.ngspice and + sm141064.ngspice) — pointing at either file inside it also works. + """ + corner = corner_override or _DEFAULT_CORNER[pdk_name] + root = Path(os.environ.get("PDK_ROOT", "/foss/pdks")) + + if pdk_name == "sky130": + lib = Path(path_override) if path_override else ( + root / "sky130A/libs.tech/ngspice/sky130.lib.spice") + return [f'.lib "{lib}" {corner}'], corner, lib + + # gf180: ciel keeps the active version behind a `current` pointer. + if path_override: + base = Path(path_override) + if base.is_file(): + base = base.parent + else: + ver = (root / "ciel/gf180mcu/current").read_text().strip() + base = root / "ciel/gf180mcu/versions" / ver / "gf180mcuD/libs.tech/ngspice" + design = base / "design.ngspice" + sm = base / "sm141064.ngspice" + lines = [f'.include "{design}"', f'.lib "{sm}" {corner}'] + lines += [f'.lib "{sm}" {sec}' for sec in _GF180_FIXED_SECTIONS] + return lines, corner, sm + + +def _parse_sim_log(text: str, checks: Optional[Dict[str, dict]]) -> Dict[str, Any]: + """Lightweight parse of an ngspice batch log. + + Surfaces the common environment failures explicitly (mirroring the LVS + parser) so a broken container reads as e.g. "ngspice not on PATH" rather + than the catch-all "sim inconclusive". Then extracts `.measure` results and + decides pass/fail: clean finish + no failed measure + (if a checks sidecar + is given) every measurement inside its band. + """ + summary: Dict[str, Any] = { + "is_pass": False, + "conclusion": "sim inconclusive", + "measures": {}, + "failed_measures": [], + "check_violations": [], + "rows": [], + "raw_tail": text[-1500:] if text else "", + } + if not text: + return summary + + if "ngspice: command not found" in text or "ngspice: not found" in text: + summary["conclusion"] = "ngspice binary not on PATH" + return summary + if "could not find include file" in text or "can't open file" in text.lower(): + summary["conclusion"] = "missing include / model lib" + return summary + # Binned-model resolution failure: the model libs loaded, but the device's + # W/L matched no bin. With glayout's micron-unit netlists this is almost + # always a missing/wrong `.option scale` (see _DEFAULT_SCALE). + if "could not find a valid modelname" in text.lower(): + summary["conclusion"] = ("no model bin for device W/L " + "(units / .option scale mismatch?)") + return summary + # Model card not found / unresolved subckt — almost always a corner-section + # mismatch or a DUT subckt name that doesn't match the .subckt in the + # reference netlist. + if re.search(r"unable to find definition of model|could not find a model|unknown subckt|unknown subcircuit", text, re.I): + summary["conclusion"] = "missing model / unresolved subckt" + return summary + + fatal = re.search(r"\bfatal\b|singular matrix|Timestep too small|simulation (?:aborted|interrupted)|iteration limit reached", text, re.I) + + # Capture `name = 1.23e-9` style measurement echoes, and any that failed. + # ngspice prints `.measure` results under a "Measurements for <...> Analysis" + # header; its end-of-run resource report (e.g. "Stack = 0 bytes.") also looks + # like a bare `name = value`, so scope extraction to the measurement block(s) + # and stop at the blank line that closes each. Fall back to a global scan + # when no header is present, preserving prior behaviour. + meas_re = re.compile(r"^\s*([A-Za-z_]\w*)\s*=\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\b") + headers = list(re.finditer(r"Measurements for .*?Analysis.*$", text, re.I | re.M)) + if headers: + for h in headers: + started = False + for line in text[h.end():].splitlines(): + mm = meas_re.match(line) + if mm: + summary["measures"][mm.group(1)] = float(mm.group(2)) + started = True + elif started and not line.strip(): + break # blank line closes the measurement block + else: + for mm in meas_re.finditer(text): + summary["measures"][mm.group(1)] = float(mm.group(2)) + for m in re.finditer(r"(?:measure(?:ment)?\s+)?([A-Za-z_]\w*)\s*(?:=\s*failed|\bfailed\b)", text, re.I): + if m.group(1).lower() not in ("the", "measurement"): + summary["failed_measures"].append(m.group(1)) + + if fatal: + # Attach the log lines around the error so summary.json shows the + # actual message (raw_tail often only catches the ngspice banner, + # which is printed after the error). + lines = text.splitlines() + for i, ln in enumerate(lines): + if fatal.group(0) in ln: + summary["error_context"] = "\n".join(lines[max(0, i - 6): i + 7]) + break + summary["conclusion"] = f"ngspice error: {fatal.group(0).strip()}" + return summary + if summary["failed_measures"]: + summary["conclusion"] = "measurement failed" + return summary + + # Build a per-measurement table: every measured value, annotated with its + # band + verdict when a checks sidecar covers it. Built unconditionally so + # the report can render a table whether the cell passes or fails, and so + # the pass/fail verdict and the displayed table can never disagree. + checked = checks or {} + for name, val in summary["measures"].items(): + band = checked.get(name) + lo = band.get("min") if band else None + hi = band.get("max") if band else None + if band is None: + verdict = "n/a" + elif (lo is not None and val < lo) or (hi is not None and val > hi): + verdict = "FAIL" + lo_s = "-inf" if lo is None else format(lo, "g") + hi_s = "inf" if hi is None else format(hi, "g") + summary["check_violations"].append(f"{name}={val:g} outside [{lo_s}, {hi_s}]") + else: + verdict = "PASS" + summary["rows"].append( + {"name": name, "value": val, "min": lo, "max": hi, "verdict": verdict} + ) + # Bands declared in the sidecar with no matching measurement in the log. + for name, band in checked.items(): + if name not in summary["measures"]: + summary["check_violations"].append(f"{name}: not measured") + summary["rows"].append( + {"name": name, "value": None, + "min": band.get("min"), "max": band.get("max"), "verdict": "MISSING"} + ) + + if summary["check_violations"]: + summary["conclusion"] = "out-of-spec measurement" + return summary + + summary["is_pass"] = True + summary["conclusion"] = "sim passed" + return summary + + +def _fmt_eng(x: Optional[float]) -> str: + """Compact engineering formatting for report tables (10.0u, 1.8m, -4.43e-13).""" + if x is None: + return "—" + if x == 0: + return "0" + ax = abs(x) + for suffix, scale in (("G", 1e9), ("M", 1e6), ("k", 1e3), ("", 1.0), + ("m", 1e-3), ("u", 1e-6), ("n", 1e-9), ("p", 1e-12)): + if ax >= scale: + return f"{x / scale:.4g}{suffix}" + return f"{x:.4g}" # sub-pico: leave in scientific form + + +def _table_rows(rows: List[dict]) -> List[List[str]]: + """Normalise measurement rows to string cells for the tables below.""" + def band(r: dict) -> str: + lo, hi = r.get("min"), r.get("max") + if lo is None and hi is None: + return "—" + return f"{_fmt_eng(lo)} … {_fmt_eng(hi)}" + return [[r["name"], _fmt_eng(r["value"]), band(r), r["verdict"]] for r in rows] + + +def _ascii_table(rows: List[dict]) -> str: + """Fixed-width table for the console log / JUnit system-out.""" + if not rows: + return " (no measurements captured)" + header = ["measurement", "value", "limits", "result"] + data = _table_rows(rows) + widths = [max(len(header[i]), *(len(row[i]) for row in data)) for i in range(4)] + line = lambda cells: " " + " | ".join(c.ljust(widths[i]) for i, c in enumerate(cells)) + sep = " " + "-+-".join("-" * w for w in widths) + return "\n".join([line(header), sep, *(line(r) for r in data)]) + + +def _markdown_table(rows: List[dict]) -> str: + """GitHub-flavoured Markdown table for the Actions step summary.""" + if not rows: + return "_(no measurements captured)_" + out = ["| measurement | value | limits | result |", "|---|---|---|---|"] + for name, value, limits, verdict in _table_rows(rows): + badge = {"PASS": "✅ PASS", "FAIL": "❌ FAIL", + "MISSING": "⚠️ MISSING", "n/a": "—"}.get(verdict, verdict) + out.append(f"| `{name}` | {value} | {limits} | {badge} |") + return "\n".join(out) + + +def _write_step_summary(results: List[dict], pdk: str, counts: Dict[str, int]) -> None: + """Append a per-cell measurement table to $GITHUB_STEP_SUMMARY. + + No-op when the env var is unset (i.e. running locally), so this stays + invisible outside CI. In CI it renders as real tables on the run's Summary + page, alongside the JUnit check the publish step already posts. + """ + path = os.environ.get("GITHUB_STEP_SUMMARY") + if not path: + return + lines = [ + f"## ngspice checks — {pdk}", + "", + f"**{counts['pass']} passed · {counts['fail']} failed · " + f"{counts['error']} error · {counts['skip']} skipped**", + "", + ] + badge = {"pass": "✅", "fail": "❌", "error": "💥", "skip": "⏭️"} + for r in results: + rows = r.get("summary", {}).get("rows", []) + lines.append(f"### {badge.get(r['status'], '')} `{r['cell']}` — {r['status'].upper()}") + if r.get("message") and r["status"] != "pass": + lines.append(f"> {r['message']}") + lines.append("") + lines.append(_markdown_table(rows)) + lines.append("") + try: + with open(path, "a", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + except OSError: + pass + + +def _write_junit(results: List[dict], pdk: str, out: Path) -> None: + suite = ET.Element( + "testsuite", + attrib={ + "name": f"glayout-sim-{pdk}", + "tests": str(len(results)), + "failures": str(sum(1 for r in results if r["status"] == "fail")), + "errors": str(sum(1 for r in results if r["status"] == "error")), + "skipped": str(sum(1 for r in results if r["status"] == "skip")), + }, + ) + for r in results: + case = ET.SubElement( + suite, "testcase", + attrib={"classname": f"sim.{pdk}", "name": r["cell"]}, + ) + if r["status"] == "fail": + ET.SubElement(case, "failure", attrib={"message": r.get("message", "sim failed")}).text = json.dumps(r, indent=2) + elif r["status"] == "error": + ET.SubElement(case, "error", attrib={"message": r.get("message", "sim error")}).text = json.dumps(r, indent=2) + elif r["status"] == "skip": + ET.SubElement(case, "skipped", attrib={"message": r.get("message", "skipped")}) + # Attach the measurement table to every case so it shows in the JUnit + # report body regardless of pass/fail. + rows = r.get("summary", {}).get("rows", []) + if rows: + ET.SubElement(case, "system-out").text = "\n" + _ascii_table(rows) + "\n" + ET.ElementTree(suite).write(out, encoding="utf-8", xml_declaration=True) + + +def _enumerate_cells(inputs_dir: Path, tb_dir: Path) -> Tuple[List[str], int]: + """Cells that have BOTH a reference netlist and a testbench. + + Returns (simulatable_cells, total_netlists) so main() can tell the + difference between "broken artifact" (no netlists at all) and "nothing + wired up yet" (netlists exist but no testbenches). + """ + nets = {p.stem for p in (inputs_dir / "netlists").glob("*.spice")} if (inputs_dir / "netlists").is_dir() else set() + tbs = {p.stem for p in tb_dir.glob("*.spice")} if tb_dir.is_dir() else set() + return sorted(nets & tbs), len(nets) + + +def _assemble_deck(name: str, netlist_path: Path, testbench_path: Path, + prelude_lines: List[str], corner: str, deck_path: Path) -> None: + """Write a self-contained ngspice deck: prelude + DUT netlist + testbench. + + ``prelude_lines`` is the per-PDK deck prelude: the `.option` lines from + ``_deck_option_lines()`` (e.g. the gf180 micron scale) followed by the + model preamble from ``_model_deck_lines()`` — a single ``.lib + `` for sky130, or the design.ngspice include plus the + sm141064.ngspice section set for gf180. + + POST-LAYOUT (PEX) EXTENSION: to simulate parasitics instead of the + reference netlist, extract a `.pex.spice` from gds/.gds via a + magic batch step (extract all; ext2spice cthresh 0; extresist all; + ext2spice extresist on) and point `.include` at that file instead of + `netlist_path`. Gate it behind a --pex flag and skip gf180 (substrate + mis-extraction, as in the LVS runner). + """ + body = testbench_path.read_text() + # Drop a trailing `.end` from the testbench so ours is the only one. + body = re.sub(r"^\s*\.end\s*$", "", body, flags=re.M | re.I).rstrip() + deck = ( + f"* auto-assembled deck for {name} ({corner})\n" + + "\n".join(prelude_lines) + "\n" + + f'.include "{netlist_path}"\n' + + f"{body}\n" + + ".end\n" + ) + deck_path.write_text(deck) + + +def _run_one_sim(item: dict) -> dict: + """Simulate one cell. Designed for ProcessPoolExecutor.""" + name = item["name"] + pdk_name = item["pdk"] + out_dir = Path(item["out_dir"]) + cell_dir = Path(item["rpt_dir"]) / "sim" / name + cell_dir.mkdir(parents=True, exist_ok=True) + deck_path = cell_dir / f"{name}.deck.spice" + log_path = cell_dir / f"{name}.log" + result: Dict[str, Any] = {"cell": name, "pdk": pdk_name, "status": "skip"} + + try: + print(f"[SIM] {name}", flush=True) + # Pass bands come from the single consolidated checks file, loaded once + # in main() and handed to each cell here. A per-cell .checks.json + # sidecar still works as a legacy fallback for cells not listed in it. + checks: Optional[Dict[str, dict]] = item.get("checks") + if checks is None: + sidecar = Path(item["testbench_path"]).with_suffix(".checks.json") + if sidecar.exists(): + checks = json.loads(sidecar.read_text()) + + _assemble_deck( + name, + Path(item["netlist_path"]), + Path(item["testbench_path"]), + item["prelude_lines"], + item["corner"], + deck_path, + ) + + # Batch mode with NO `.control` block: avoids the well-known + # double-execution when `-b` is combined with `.control ... run`. + proc = subprocess.run( + ["ngspice", "-b", "-o", str(log_path), str(deck_path)], + capture_output=True, text=True, timeout=item["timeout"], + ) + captured = (proc.stdout or "") + "\n" + (proc.stderr or "") + log_text = (log_path.read_text() if log_path.exists() else "") + "\n" + captured + except subprocess.TimeoutExpired: + result.update({"status": "error", "message": f"sim timed out after {item['timeout']}s"}) + print(f"[ERROR] {name}: timed out", flush=True) + return result + except Exception as exc: + result.update({"status": "error", "message": f"sim failed: {exc}", "trace": traceback.format_exc()}) + print(f"[ERROR] {name}: {exc}", flush=True) + return result + + parsed = _parse_sim_log(log_text, checks) + result.update({ + "summary": parsed, + "returncode": proc.returncode, + "deck": str(deck_path.relative_to(out_dir)), + "log": str(log_path.relative_to(out_dir)) if log_path.exists() else None, + }) + if parsed["is_pass"] and proc.returncode == 0: + result["status"] = "pass" + result["message"] = "sim passed" + elif parsed["check_violations"]: + result["status"] = "fail" + result["message"] = "; ".join(parsed["check_violations"])[:300] + else: + result["status"] = "fail" + result["message"] = parsed["conclusion"] + print(f"[{result['status'].upper()}] {name}: {result.get('message','')}", flush=True) + rows = parsed.get("rows", []) + if rows: + print(_ascii_table(rows), flush=True) + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--pdk", required=True, choices=["sky130", "gf180"]) + parser.add_argument( + "--inputs-dir", required=True, + help="Directory containing netlists/.spice (DRC artifact root for the PDK).", + ) + parser.add_argument("--out-dir", default="sim_results") + parser.add_argument( + "--testbench-dir", default=None, + help="Directory of .spice testbenches (and optional checks.json / " + ".checks.json). Default: tests/sim/testbenches/, falling " + "back to the flat tests/sim/testbenches/ if the per-PDK directory " + "is absent.", + ) + parser.add_argument( + "--model-lib", default=None, + help="Override model location: for sky130 the .lib file itself; for " + "gf180 the ngspice models directory holding design.ngspice and " + "sm141064.ngspice.", + ) + parser.add_argument( + "--checks-file", default=None, + help="Single JSON file of pass bands keyed by cell name " + "(default: /checks.json). A per-cell " + ".checks.json sidecar is used as a fallback for any cell " + "not listed in this file.") + parser.add_argument( + "--scale", default=None, + help="Override the injected `.option scale` (default: gf180=1e-6, " + "sky130=none since its lib sets the scale itself). Pass 'none' " + "to disable.", + ) + parser.add_argument( + "--corner", default=None, + help="Override corner section (default: sky130=tt, gf180=typical). For " + "gf180 this swaps only the sm141064 MOS section; passive sections " + "stay at their typical variants.", + ) + parser.add_argument( + "--cells", default=None, + help="Comma-separated cell names; default runs every cell with both netlist+testbench.", + ) + parser.add_argument( + "--skip-cells", default="", + help="Comma-separated cell names to skip when --cells is not specified.", + ) + parser.add_argument("--timeout", type=int, default=600, help="Per-cell ngspice timeout in seconds.") + parser.add_argument( + "--jobs", "-j", type=int, default=max(1, (os.cpu_count() or 2) - 1), + help="Worker processes for parallel sims (default: cpu_count-1).", + ) + args = parser.parse_args() + + inputs_dir = Path(args.inputs_dir).resolve() + if args.testbench_dir: + tb_dir = Path(args.testbench_dir).resolve() + else: + # Per-PDK layout first (rails/probe points/bands differ per PDK), + # flat legacy layout as fallback. + tb_dir = REPO_ROOT / "tests" / "sim" / "testbenches" / args.pdk + if not tb_dir.is_dir(): + tb_dir = REPO_ROOT / "tests" / "sim" / "testbenches" + tb_dir = tb_dir.resolve() + out_dir = Path(args.out_dir).resolve() + rpt_dir = out_dir / "reports" + out_dir.mkdir(parents=True, exist_ok=True) + rpt_dir.mkdir(parents=True, exist_ok=True) + print(f"testbench dir: {tb_dir}") + + cells, total_nets = _enumerate_cells(inputs_dir, tb_dir) + if total_nets == 0: + print(f"no netlists found under {inputs_dir}/netlists (broken DRC artifact?)", file=sys.stderr) + return 2 + if not cells: + # Netlists exist but nothing has a testbench yet. Treat as a no-op pass + # (write summary.json, no junit.xml) so adding this workflow doesn't red + # the build before testbenches are authored — the publish step is gated + # on junit.xml existing, so it just skips. + msg = f"no testbenches in {tb_dir} matching the {total_nets} available netlist(s); nothing to simulate" + print(msg) + (out_dir / "summary.json").write_text(json.dumps( + {"pdk": args.pdk, "total": 0, "note": msg}, indent=2)) + return 0 + + model_lines, corner, model_path = _model_deck_lines(args.pdk, args.model_lib, args.corner) + if not model_path.exists(): + print(f"warning: model library not found at {model_path} " + f"(PDK_ROOT={os.environ.get('PDK_ROOT', '/foss/pdks')})", file=sys.stderr) + prelude_lines = _deck_option_lines(args.pdk, args.scale) + model_lines + print(f"deck prelude (corner {corner}):\n " + "\n ".join(prelude_lines)) + + # Load the single consolidated checks file once: { "": {band...} }. + checks_file = Path(args.checks_file) if args.checks_file else (tb_dir / "checks.json") + all_checks: Dict[str, dict] = {} + if checks_file.exists(): + try: + all_checks = json.loads(checks_file.read_text()) + print(f"checks file: {checks_file} ({len(all_checks)} cells with bands)") + except json.JSONDecodeError as exc: + print(f"warning: could not parse {checks_file}: {exc}", file=sys.stderr) + else: + print(f"no consolidated checks file at {checks_file} " + f"(cells run as smoke tests unless a sidecar exists)") + + if args.cells: + wanted = {c.strip() for c in args.cells.split(",") if c.strip()} + missing = wanted - set(cells) + if missing: + print(f"warning: cells without netlist+testbench: {sorted(missing)}", file=sys.stderr) + cells = [c for c in cells if c in wanted] + elif args.skip_cells: + skip = {c.strip() for c in args.skip_cells.split(",") if c.strip()} + for s in [c for c in cells if c in skip]: + print(f"skipping cell on the --skip-cells list: {s}") + cells = [c for c in cells if c not in skip] + + work_items = [ + { + "name": name, + "pdk": args.pdk, + "netlist_path": str(inputs_dir / "netlists" / f"{name}.spice"), + "testbench_path": str(tb_dir / f"{name}.spice"), + "checks": all_checks.get(name), + "prelude_lines": prelude_lines, + "corner": corner, + "out_dir": str(out_dir), + "rpt_dir": str(rpt_dir), + "timeout": args.timeout, + } + for name in cells + ] + jobs = max(1, min(args.jobs, len(work_items))) + print(f"running {len(work_items)} cells with {jobs} worker(s)") + from concurrent.futures import ProcessPoolExecutor, as_completed + results: List[dict] = [] + if jobs == 1: + for item in work_items: + results.append(_run_one_sim(item)) + else: + with ProcessPoolExecutor(max_workers=jobs) as pool: + futures = {pool.submit(_run_one_sim, item): item["name"] for item in work_items} + for fut in as_completed(futures): + results.append(fut.result()) + name_order = {n: i for i, n in enumerate(cells)} + results.sort(key=lambda r: name_order.get(r["cell"], len(name_order))) + + summary = { + "pdk": args.pdk, + "total": len(results), + "pass": sum(1 for r in results if r["status"] == "pass"), + "fail": sum(1 for r in results if r["status"] == "fail"), + "error": sum(1 for r in results if r["status"] == "error"), + "skip": sum(1 for r in results if r["status"] == "skip"), + "results": results, + } + (out_dir / "summary.json").write_text(json.dumps(summary, indent=2)) + _write_junit(results, args.pdk, out_dir / "junit.xml") + _write_step_summary(results, args.pdk, summary) + print(json.dumps({k: v for k, v in summary.items() if k != "results"}, indent=2)) + return 0 if summary["fail"] == 0 and summary["error"] == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/tests/sim/testbenches/gf180/checks.json b/tests/sim/testbenches/gf180/checks.json new file mode 100644 index 00000000..ddc9613e --- /dev/null +++ b/tests/sim/testbenches/gf180/checks.json @@ -0,0 +1,92 @@ +{ + "current_mirror_nfet": { + "iout_op": { + "min": 0.0000149, + "max": 0.0000183 + }, + "mirror_err": { + "min": 0.591, + "max": 0.723 + } + }, + "current_mirror_pfet": { + "iout_op": { + "min": 0.0000107, + "max": 0.0000132 + }, + "mirror_err": { + "min": 0.172, + "max": 0.211 + } + }, + "diff_pair": { + "i1_bal": { + "min": 0.00000901, + "max": 0.0000111 + }, + "i2_bal": { + "min": 0.00000901, + "max": 0.0000111 + }, + "itail_rec": { + "min": 0.000018, + "max": 0.0000221 + }, + "imbalance": { + "min": -0.01, + "max": 0.01 + } + }, + "diff_pair_ibias": { + "i1_bal": { + "min": 0.00000897, + "max": 0.000011 + }, + "i2_bal": { + "min": 0.00000897, + "max": 0.000011 + }, + "itail_rec": { + "min": 0.0000179, + "max": 0.000022 + }, + "imbalance": { + "min": -0.01, + "max": 0.01 + } + }, + "flipped_voltage_follower": { + "follow_slope": { + "min": 0.685, + "max": 0.838 + }, + "level_shift": { + "min": 0.787, + "max": 0.963 + } + }, + "transmission_gate": { + "ron_lo": { + "min": 1620, + "max": 1990 + }, + "ron_mid": { + "min": 2560, + "max": 3140 + }, + "ron_hi": { + "min": 5170, + "max": 6320 + } + }, + "low_voltage_cmirror": { + "err1": { + "min": -0.02, + "max": 0.02 + }, + "err2": { + "min": -0.02, + "max": 0.02 + } + } +} diff --git a/tests/sim/testbenches/gf180/current_mirror_nfet.spice b/tests/sim/testbenches/gf180/current_mirror_nfet.spice new file mode 100644 index 00000000..25880d15 --- /dev/null +++ b/tests/sim/testbenches/gf180/current_mirror_nfet.spice @@ -0,0 +1,29 @@ +* Testbench: NFET current mirror — gf180 (3.3 V, nfet_03v3) +* DUT subckt: CMIRROR VREF VOUT VSS B (VSS = source rail, B = bulk) +* +* Functional check: inject a reference current into the diode-connected side +* (VREF) and read back the mirrored current on the output side while sweeping +* the output voltage. iout_op is the mirrored current at mid-rail (device +* well into saturation); mirror_err is its relative error vs IREF, which +* captures both W/L mismatch and channel-length modulation at that point. +* +* run_cell_sim.py injects the gf180 model setup and .includes the DUT +* netlist, so this file is stimulus + analysis ONLY. +.param VDD=3.3 +.param IREF=10u +* Source rail + bulk of the nfet mirror. +Vss VSS 0 DC 0 +* Reference: push IREF into the diode-connected VREF node. +Iref 0 VREF DC {IREF} +* Output: force VOUT with a swept source and read the drain current through it. +Vout VOUT 0 DC 0 +* DUT: VREF VOUT VSS B +X1 VREF VOUT VSS VSS CMIRROR +.dc Vout 0 {VDD} 0.02 +* The Vout source SUPPLIES the nfet drain, so i(Vout) is negative; flip sign. +* AT literal 1.65 = VDD/2 operating point. +.measure dc iout_raw FIND i(Vout) AT=1.65 +.measure dc iout_op param='-iout_raw' +* Literal 10e-6 must match IREF above. +.measure dc mirror_err param='(iout_op - 10e-6)/10e-6' +.end diff --git a/tests/sim/testbenches/gf180/current_mirror_pfet.spice b/tests/sim/testbenches/gf180/current_mirror_pfet.spice new file mode 100644 index 00000000..ddcb3d09 --- /dev/null +++ b/tests/sim/testbenches/gf180/current_mirror_pfet.spice @@ -0,0 +1,30 @@ +* Testbench: PFET current mirror — gf180 (3.3 V, pfet_03v3) +* DUT subckt: CMIRROR VREF VOUT VSS B +* NOTE: same subckt name/ports as the nfet variant, but built from pfets, so +* the port called "VSS" is the pfet SOURCE rail -> tie it (and bulk B) to VCC. +* +* Functional check: pull a reference current out of the diode-connected side +* (pfet diode sources IREF into VREF from VCC), then read the mirrored +* current the output device sources into a swept VOUT node. iout_op at +* mid-rail, mirror_err relative to IREF. +* +* run_cell_sim.py injects the gf180 model setup and .includes the DUT +* netlist, so this file is stimulus + analysis ONLY. +.param VDD=3.3 +.param IREF=10u +* Source rail + bulk of the pfet mirror (the subckt's "VSS"/"B" ports). +Vcc VCC 0 DC {VDD} +* Reference: pull IREF out of the diode-connected VREF node to ground. +Iref VREF 0 DC {IREF} +* Output: force VOUT with a swept source and read the sourced current. +Vout VOUT 0 DC 0 +* DUT: VREF VOUT VSS(=VCC rail) B(=VCC) +X1 VREF VOUT VCC VCC CMIRROR +.dc Vout 0 {VDD} 0.02 +* The pfet SOURCES current into the VOUT node, which flows into the Vout +* source's + terminal -> i(Vout) is already positive. AT literal 1.65 = VDD/2. +.measure dc iout_raw FIND i(Vout) AT=1.65 +.measure dc iout_op param='iout_raw' +* Literal 10e-6 must match IREF above. +.measure dc mirror_err param='(iout_op - 10e-6)/10e-6' +.end diff --git a/tests/sim/testbenches/gf180/diff_pair.spice b/tests/sim/testbenches/gf180/diff_pair.spice new file mode 100644 index 00000000..8b32a59b --- /dev/null +++ b/tests/sim/testbenches/gf180/diff_pair.spice @@ -0,0 +1,38 @@ +* Testbench: NFET differential pair — gf180 (3.3 V, nfet_03v3) +* DUT subckt: DIFF_PAIR VP VN VDD1 VDD2 VTAIL B +* VP/VN = gates, VDD1 = drain of the VP side, VDD2 = drain of the VN side, +* VTAIL = common source, B = bulk (tie to ground for nfets). +* +* Functional check: bias with an ideal ITAIL sink, drive both gates to the +* same common mode, and verify the tail current splits evenly and is fully +* recovered at the drains: i1_bal ~= i2_bal ~= ITAIL/2, itail_rec ~= ITAIL, +* imbalance ~= 0. Each drain gets its own supply source so the branch +* currents can be read individually. +* +* run_cell_sim.py injects the gf180 model setup and .includes the DUT +* netlist, so this file is stimulus + analysis ONLY. +.param VDD=3.3 +.param ITAIL=20u +.param VCM=1.65 +* Per-branch drain supplies (double as current sensors). +Vd1 VDD1 0 DC {VDD} +Vd2 VDD2 0 DC {VDD} +* Bulk rail. +Vbulk VB 0 DC 0 +* Ideal tail sink: pulls ITAIL out of the common-source node. +Itail VTAIL 0 DC {ITAIL} +* Gate drive: VN pinned at VCM, VP swept through it; balance read AT VP=VCM. +Vp VP 0 DC {VCM} +Vn VN 0 DC {VCM} +* DUT: VP VN VDD1 VDD2 VTAIL B +X1 VP VN VDD1 VDD2 VTAIL VB DIFF_PAIR +.dc Vp 1.35 1.95 0.005 +* Drain supplies deliver the branch currents -> i(Vd*) negative; flip sign. +* AT literal 1.65 must match VCM above (balanced point). +.measure dc i1_raw FIND i(Vd1) AT=1.65 +.measure dc i2_raw FIND i(Vd2) AT=1.65 +.measure dc i1_bal param='-i1_raw' +.measure dc i2_bal param='-i2_raw' +.measure dc itail_rec param='i1_bal + i2_bal' +.measure dc imbalance param='(i1_bal - i2_bal)/(i1_bal + i2_bal)' +.end diff --git a/tests/sim/testbenches/gf180/diff_pair_ibias.spice b/tests/sim/testbenches/gf180/diff_pair_ibias.spice new file mode 100644 index 00000000..d7cdc883 --- /dev/null +++ b/tests/sim/testbenches/gf180/diff_pair_ibias.spice @@ -0,0 +1,38 @@ +* Testbench: NFET diff pair with current-mirror tail bias — gf180 (3.3 V) +* DUT subckt: DIFFPAIR_CMIRROR_BIAS VP VN VDD1 VDD2 IBIAS VSS B +* Same pair as diff_pair, but the tail is the output of an internal nfet +* mirror whose diode-connected reference is the IBIAS port. +* +* Functional check: inject IB into IBIAS and verify the mirrored tail current +* splits evenly across the pair and is recovered at the drains. itail_rec +* also implicitly checks the internal mirror (ratio ~1, minus its error at +* the tail-node operating point). +* +* run_cell_sim.py injects the gf180 model setup and .includes the DUT +* netlist, so this file is stimulus + analysis ONLY. +.param VDD=3.3 +.param IB=20u +.param VCM=1.65 +* Per-branch drain supplies (double as current sensors). +Vd1 VDD1 0 DC {VDD} +Vd2 VDD2 0 DC {VDD} +* Ground rail + bulk. +Vss VSS 0 DC 0 +Vbulk VB 0 DC 0 +* Bias: push IB into the mirror's diode-connected IBIAS node. +Ibias 0 IBIAS DC {IB} +* Gate drive: VN pinned at VCM, VP swept through it; balance read AT VP=VCM. +Vp VP 0 DC {VCM} +Vn VN 0 DC {VCM} +* DUT: VP VN VDD1 VDD2 IBIAS VSS B +X1 VP VN VDD1 VDD2 IBIAS VSS VB DIFFPAIR_CMIRROR_BIAS +.dc Vp 1.35 1.95 0.005 +* Drain supplies deliver the branch currents -> i(Vd*) negative; flip sign. +* AT literal 1.65 must match VCM above (balanced point). +.measure dc i1_raw FIND i(Vd1) AT=1.65 +.measure dc i2_raw FIND i(Vd2) AT=1.65 +.measure dc i1_bal param='-i1_raw' +.measure dc i2_bal param='-i2_raw' +.measure dc itail_rec param='i1_bal + i2_bal' +.measure dc imbalance param='(i1_bal - i2_bal)/(i1_bal + i2_bal)' +.end diff --git a/tests/sim/testbenches/gf180/flipped_voltage_follower.spice b/tests/sim/testbenches/gf180/flipped_voltage_follower.spice new file mode 100644 index 00000000..3e8a9a6e --- /dev/null +++ b/tests/sim/testbenches/gf180/flipped_voltage_follower.spice @@ -0,0 +1,39 @@ +* Testbench: flipped voltage follower — gf180 (3.3 V, nfet_03v3) +* DUT subckt: FLIPPED_VOLTAGE_FOLLOWER VIN VBULK VOUT Ib +* X0: D=Ib G=VIN S=VOUT; X1: D=VOUT G=Ib S=VBULK. Bias current is injected +* into the Ib node (drain of X0 / gate of X1); VBULK is the ground rail. +* +* Functional check: the FVF is a level-shifting follower, so VOUT should +* track VIN with near-unity slope and a roughly constant Vgs level shift. +* follow_slope = dVOUT/dVIN across the follow window; level_shift = +* VIN - VOUT at the window's midpoint. +* +* The follow window is bounded above by X0 leaving saturation (its drain +* sits at V(Ib) ~= one Vgs), so the sweep/probe points live in the low-VIN +* region. If the first run shows the AT points outside the linear region, +* nudge them and recalibrate the bands. +* +* run_cell_sim.py injects the gf180 model setup and .includes the DUT +* netlist, so this file is stimulus + analysis ONLY. +.param IB=10u +* Ground rail / bulk. +Vbulk VBULK 0 DC 0 +* Bias: push IB into the Ib node. +Ibias 0 NBIAS DC {IB} +* Convergence aid: gives the ideal current source a DC path when both +* devices are off at the low end of the sweep. 100Meg draws ~10 pA at the +* operating point -- negligible vs IB. +Rconv NBIAS 0 100Meg +* Swept input. +Vin VIN 0 DC 0 +* DUT: VIN VBULK VOUT Ib +X1 VIN VBULK VOUT NBIAS FLIPPED_VOLTAGE_FOLLOWER +.dc Vin 0.9 2.0 0.005 +* Follow window probes (literals; keep consistent with the slope divisor). +.measure dc vout_a FIND v(VOUT) AT=1.0 +.measure dc vout_b FIND v(VOUT) AT=1.4 +.measure dc follow_slope param='(vout_b - vout_a)/0.4' +* Level shift (~Vgs of X0) at the window midpoint; literal 1.2 = AT point. +.measure dc vout_ls FIND v(VOUT) AT=1.2 +.measure dc level_shift param='1.2 - vout_ls' +.end diff --git a/tests/sim/testbenches/gf180/low_voltage_cmirror.spice b/tests/sim/testbenches/gf180/low_voltage_cmirror.spice new file mode 100644 index 00000000..ee881e1e --- /dev/null +++ b/tests/sim/testbenches/gf180/low_voltage_cmirror.spice @@ -0,0 +1,37 @@ +* Testbench: low-voltage (FVF-biased cascode) current mirror — gf180 (3.3 V) +* DUT subckt: Low_voltage_current_mirror IBIAS1 IBIAS2 GND IOUT1 IOUT2 +* Two internal flipped-voltage-follower branches turn the injected IBIAS1 / +* IBIAS2 currents into the cascode and bottom gate biases; IOUT1/IOUT2 are +* two cascoded nfet output branches that should each sink a copy of the +* bias current with low compliance voltage. +* +* Functional check: inject IB into both bias ports, force both outputs, and +* verify each output sinks ~IB: err1/err2 are the relative errors of the two +* copies at a mid-rail output voltage (IOUT1 swept to also exercise +* compliance; IOUT2 held at the operating point). +* +* run_cell_sim.py injects the gf180 model setup and .includes the DUT +* netlist, so this file is stimulus + analysis ONLY. +.param VDD=3.3 +.param IB=10u +.param VOP=1.65 +* NOTE: ngspice aliases the node name "GND" to node 0, so a `Vgnd GND 0 0` +* rail source would be a shorted vsource (fatal). Wire the subckt's GND port +* straight to node 0 instead. +* Bias injections into the two FVF-based reference branches. +Ib1 0 IBIAS1 DC {IB} +Ib2 0 IBIAS2 DC {IB} +* Outputs: IOUT1 swept, IOUT2 pinned at the operating point. +Vout1 IOUT1 0 DC 0 +Vout2 IOUT2 0 DC {VOP} +* DUT: IBIAS1 IBIAS2 GND(=0) IOUT1 IOUT2 +X1 IBIAS1 IBIAS2 0 IOUT1 IOUT2 Low_voltage_current_mirror +.dc Vout1 0 {VDD} 0.02 +* The output branches SINK current supplied by the Vout sources -> +* i(Vout*) negative; flip sign. AT literal 1.65 must match VOP above. +.measure dc iout1_raw FIND i(Vout1) AT=1.65 +.measure dc iout2_raw FIND i(Vout2) AT=1.65 +* Literal 10e-6 must match IB above. +.measure dc err1 param='(-iout1_raw - 10e-6)/10e-6' +.measure dc err2 param='(-iout2_raw - 10e-6)/10e-6' +.end diff --git a/tests/sim/testbenches/gf180/transmission_gate.spice b/tests/sim/testbenches/gf180/transmission_gate.spice new file mode 100644 index 00000000..8a23ec53 --- /dev/null +++ b/tests/sim/testbenches/gf180/transmission_gate.spice @@ -0,0 +1,36 @@ +* Testbench: CMOS transmission gate — gf180 (3.3 V, nfet_03v3/pfet_03v3) +* DUT subckt: Transmission_Gate VIN VSS VOUT VCC VGP VGN +* +* Functional check: with the gate ENABLED (VGN high, VGP low) the switch +* conducts from VIN to VOUT with a low, roughly rail-flat on-resistance. We +* pull a fixed load current out of VOUT and measure the IR drop across the +* closed switch at low / mid / high common mode; Ron = (V(VIN)-V(VOUT))/ILOAD. +* The complementary nfet+pfet keep Ron bounded across the whole input range -- +* that flatness is the TG's defining property and what we test here. +* +* run_cell_sim.py injects the gf180 model setup (design.ngspice + +* sm141064.ngspice sections) and .includes the DUT netlist, so this file is +* stimulus + analysis ONLY (no .lib / no .include here). +.param VDD=3.3 +.param ILOAD=10u +* Rails feeding the DUT (nfet bulk = VSS, pfet bulk = VCC inside the subckt). +Vcc VCC 0 DC {VDD} +Vss VSS 0 DC 0 +* Gate drive: switch ON -> nmos gate high, pmos gate low. +Vgn VGN 0 DC {VDD} +Vgp VGP 0 DC 0 +* Swept input we pass through the closed switch. +Vin VIN 0 DC 0 +* Load: sink ILOAD out of VOUT so the closed switch must conduct it. +Iload VOUT 0 DC {ILOAD} +* DUT: VIN VSS VOUT VCC VGP VGN +X1 VIN VSS VOUT VCC VGP VGN Transmission_Gate +.dc Vin 0 {VDD} 0.02 +.measure dc vout_lo FIND v(VOUT) AT=0.5 +.measure dc vout_mid FIND v(VOUT) AT=1.65 +.measure dc vout_hi FIND v(VOUT) AT=2.8 +* Ron at each common mode. Literal 10e-6 must match ILOAD above. +.measure dc ron_lo param='(0.5 - vout_lo)/10e-6' +.measure dc ron_mid param='(1.65 - vout_mid)/10e-6' +.measure dc ron_hi param='(2.8 - vout_hi)/10e-6' +.end diff --git a/tests/sim/testbenches/sky130/checks.json b/tests/sim/testbenches/sky130/checks.json new file mode 100644 index 00000000..6b0c1cd7 --- /dev/null +++ b/tests/sim/testbenches/sky130/checks.json @@ -0,0 +1,35 @@ +{ + "current_mirror_nfet": { + "iout_op": { "min": 11.1e-6, "max": 13.6e-6 }, + "mirror_err": { "min": 0.213, "max": 0.260 } + }, + "current_mirror_pfet": { + "iout_op": { "min": 11.6e-6, "max": 14.2e-6 }, + "mirror_err": { "min": 0.262, "max": 0.320 } + }, + "diff_pair": { + "i1_bal": { "min": 9e-6, "max": 11e-6 }, + "i2_bal": { "min": 9e-6, "max": 11e-6 }, + "itail_rec": { "min": 18e-6, "max": 22e-6 }, + "imbalance": { "min": -0.01, "max": 0.01 } + }, + "diff_pair_ibias": { + "i1_bal": { "min": 8.1e-6, "max": 9.9e-6 }, + "i2_bal": { "min": 8.1e-6, "max": 9.9e-6 }, + "itail_rec": { "min": 16.2e-6, "max": 19.8e-6 }, + "imbalance": { "min": -0.01, "max": 0.01 } + }, + "flipped_voltage_follower": { + "follow_slope": { "min": 0.744, "max": 0.910 }, + "level_shift": { "min": 0.632, "max": 0.772 } + }, + "transmission_gate": { + "ron_lo": { "min": 825, "max": 1008 }, + "ron_mid": { "min": 6628, "max": 8100 }, + "ron_hi": { "min": 4099, "max": 5010 } + }, + "low_voltage_cmirror": { + "err1": { "min": -0.1, "max": 0.1 }, + "err2": { "min": -0.1, "max": 0.1 } + } +} diff --git a/tests/sim/testbenches/sky130/current_mirror_nfet.spice b/tests/sim/testbenches/sky130/current_mirror_nfet.spice new file mode 100644 index 00000000..da3a0bf7 --- /dev/null +++ b/tests/sim/testbenches/sky130/current_mirror_nfet.spice @@ -0,0 +1,32 @@ +* Testbench: NFET current mirror (CMIRROR ports: VREF VOUT VSS B) +* +* Functional check: the output branch mirrors a 10 uA reference 1:1. +* +* run_cell_sim.py injects the .lib model line and .includes the DUT netlist, +* so this file is stimulus + analysis ONLY (no .lib / no .include here). +* +* Cross-PDK note: bias uses a 0.9 V output operating point well inside +* saturation for a 10 uA device on BOTH sky130 (1.8 V) and gf180 (3.3 V) +* parts. The mirror *ratio* is what we test, and that's process-independent +* as long as both transistors sit in saturation -- so one deck serves both. + +.param IREF=10u + +* Reference branch: push IREF into the diode-connected VREF node, down to VSS(0). +Iref 0 VREF DC {IREF} + +* Output branch: bias VOUT through a 0 V ammeter so i(vmeas) is the mirror current. +Vbias vom 0 DC 0.9 +Vmeas vom VOUT DC 0 + +* DUT: shared source (VSS) and bulk (B) tied to ground for the nfet mirror. +X1 VREF VOUT 0 0 CMIRROR + +* Sweep the output node 0 -> 1.8 V (triode through saturation). +.dc Vbias 0 1.8 0.02 + +.measure dc iout_op FIND i(vmeas) AT=0.9 +.measure dc iout_hi FIND i(vmeas) AT=1.6 +.measure dc mirror_err param='(iout_op-10e-6)/10e-6' + +.end diff --git a/tests/sim/testbenches/sky130/current_mirror_pfet.spice b/tests/sim/testbenches/sky130/current_mirror_pfet.spice new file mode 100644 index 00000000..3e849c19 --- /dev/null +++ b/tests/sim/testbenches/sky130/current_mirror_pfet.spice @@ -0,0 +1,34 @@ +* Testbench: PFET current mirror (CMIRROR ports: VREF VOUT VSS B) +* +* Functional check: the output branch mirrors a 10 uA reference 1:1. +* +* run_cell_sim.py injects the .lib model line and .includes the DUT netlist, +* so this file is stimulus + analysis ONLY (no .lib / no .include here). +* +* For the pfet mirror the "VSS" net is the common SOURCE and ties to the top +* rail, as does the bulk B. Rail held at 1.8 V so the deck is valid on both +* sky130 and gf180 (the gf180 3.3 V part just runs under-driven, still in +* saturation at the 0.9 V output operating point). + +.param IREF=10u + +Vdd VDD 0 DC 1.8 + +* Reference branch: pull IREF out of the diode-connected VREF node. +Iref VREF 0 DC {IREF} + +* Output branch: 0 V ammeter oriented so sourced current reads positive. +Vbias vom 0 DC 0.9 +Vmeas VOUT vom DC 0 + +* DUT: source (VSS net) and bulk (B) tied to VDD for the pfet mirror. +X1 VREF VOUT VDD VDD CMIRROR + +* Sweep the output node 0 -> 1.8 V (saturation through triode). +.dc Vbias 0 1.8 0.02 + +.measure dc iout_op FIND i(vmeas) AT=0.9 +.measure dc iout_lo FIND i(vmeas) AT=0.2 +.measure dc mirror_err param='(iout_op-10e-6)/10e-6' + +.end diff --git a/tests/sim/testbenches/sky130/diff_pair.spice b/tests/sim/testbenches/sky130/diff_pair.spice new file mode 100644 index 00000000..c3e68183 --- /dev/null +++ b/tests/sim/testbenches/sky130/diff_pair.spice @@ -0,0 +1,33 @@ +* Testbench: NMOS differential pair +* DIFF_PAIR ports: VP VN VDD1 VDD2 VTAIL B +* (VDD1/VDD2 are the drain output nodes; VTAIL is the common source.) +* +* Functional check: a tail current sunk from VTAIL steers between the two +* drain branches according to Vid = VP - VN. At VP = VN the pair is balanced +* (each branch carries ITAIL/2); the balance error and the recovered tail +* current are what we test. Matching is process-independent, mirroring the +* "ratio is what we test" idea in the current-mirror deck. +* +* run_cell_sim.py injects the .lib + DUT .include; stimulus + analysis ONLY. +* sky130 (1.8 V). One deck serves both the w=3 and w=5 DIFF_PAIR variants. +.param VDD=1.8 +.param ITAIL=20u +.param VCM=0.9 +Vb B 0 DC 0 +* Drains pulled up to VDD through 0 V ammeters -> i(vmeasX) is branch current. +Vdd DD 0 DC {VDD} +Vmeas1 DD VDD1 DC 0 +Vmeas2 DD VDD2 DC 0 +* Tail current sink (sources of the pair return through here to ground). +Itail VTAIL 0 DC {ITAIL} +* Inputs: VN held at the common mode, VP swept through it. +Vn VN 0 DC {VCM} +Vp VP 0 DC 0 +* DUT: VP VN VDD1 VDD2 VTAIL B +X1 VP VN VDD1 VDD2 VTAIL B DIFF_PAIR +.dc Vp 0.6 1.2 0.01 +.measure dc i1_bal FIND i(vmeas1) AT={VCM} +.measure dc i2_bal FIND i(vmeas2) AT={VCM} +.measure dc itail_rec param='i1_bal + i2_bal' +.measure dc imbalance param='(i1_bal - i2_bal)/(i1_bal + i2_bal)' +.end diff --git a/tests/sim/testbenches/sky130/diff_pair_ibias.spice b/tests/sim/testbenches/sky130/diff_pair_ibias.spice new file mode 100644 index 00000000..3238b8bc --- /dev/null +++ b/tests/sim/testbenches/sky130/diff_pair_ibias.spice @@ -0,0 +1,34 @@ +* Testbench: differential pair with current-mirror tail bias +* DIFFPAIR_CMIRROR_BIAS ports: VP VN VDD1 VDD2 IBIAS VSS B +* Internally the diff-pair tail (wire0) is the output of a 1:1 mirror whose +* reference is IBIAS, so the tail current = mirror(IBIAS). +* +* Functional check: set the reference current at IBIAS, then verify (a) the +* tail current recovered at the drains tracks IBIAS, and (b) the pair is +* balanced at VP = VN. Combines the mirror and diff-pair checks into the +* assembled block. +* +* run_cell_sim.py injects the .lib + DUT .include; stimulus + analysis ONLY. +* sky130 (1.8 V). +.param VDD=1.8 +.param IBIAS=20u +.param VCM=0.9 +Vss VSS 0 DC 0 +Vb B 0 DC 0 +* Reference current into the mirror's diode node. +Iref 0 IBIAS DC {IBIAS} +* Diff-pair drains to VDD through ammeters. +Vdd DD 0 DC {VDD} +Vmeas1 DD VDD1 DC 0 +Vmeas2 DD VDD2 DC 0 +* Inputs: VN at common mode, VP swept through it. +Vn VN 0 DC {VCM} +Vp VP 0 DC 0 +* DUT: VP VN VDD1 VDD2 IBIAS VSS B +X1 VP VN VDD1 VDD2 IBIAS VSS B DIFFPAIR_CMIRROR_BIAS +.dc Vp 0.6 1.2 0.01 +.measure dc i1_bal FIND i(vmeas1) AT={VCM} +.measure dc i2_bal FIND i(vmeas2) AT={VCM} +.measure dc itail_rec param='i1_bal + i2_bal' +.measure dc imbalance param='(i1_bal - i2_bal)/(i1_bal + i2_bal)' +.end diff --git a/tests/sim/testbenches/sky130/flipped_voltage_follower.spice b/tests/sim/testbenches/sky130/flipped_voltage_follower.spice new file mode 100644 index 00000000..b45a21ff --- /dev/null +++ b/tests/sim/testbenches/sky130/flipped_voltage_follower.spice @@ -0,0 +1,27 @@ +* Testbench: flipped voltage follower (FVF) +* FLIPPED_VOLTAGE_FOLLOWER ports: VIN VBULK VOUT Ib +* Internally: M0 gate=VIN, source=VOUT, drain=Ib; M1 gate=Ib, drain=VOUT, +* source=VBULK. Bias current is injected into the Ib node. +* +* Functional check: VOUT follows VIN with a roughly constant level shift +* (VOUT ~ VIN - Vgs0) -- so over the input range the small-signal gain +* (slope dVOUT/dVIN) should be near 1. Measure that slope plus the DC +* level shift. (Low output impedance is the other FVF virtue; left as an +* extension -- add a load-current step on VOUT and measure dVOUT.) +* +* run_cell_sim.py injects the .lib + DUT .include; stimulus + analysis ONLY. +* sky130 (1.8 V). Same deck covers FLIPPED_VOLTAGE_FOLLOWER_1 (identical ports). +.param VDD=1.8 +.param IB=10u +Vbulk VBULK 0 DC 0 +Vin VIN 0 DC 0 +* Inject the bias current into the Ib node (M0 drain / M1 gate). +Ibias 0 Ib DC {IB} +* DUT: VIN VBULK VOUT Ib +X1 VIN VBULK VOUT Ib FLIPPED_VOLTAGE_FOLLOWER +.dc Vin 0.6 {VDD} 0.02 +.measure dc vout_a FIND v(VOUT) AT=0.9 +.measure dc vout_b FIND v(VOUT) AT=1.2 +.measure dc level_shift param='0.9 - vout_a' +.measure dc follow_slope param='(vout_b - vout_a)/0.3' +.end diff --git a/tests/sim/testbenches/sky130/low_voltage_cmirror.spice b/tests/sim/testbenches/sky130/low_voltage_cmirror.spice new file mode 100644 index 00000000..e039b920 --- /dev/null +++ b/tests/sim/testbenches/sky130/low_voltage_cmirror.spice @@ -0,0 +1,36 @@ +* Testbench: low-voltage cascode current mirror +* subckt name: Low_voltage_current_mirror (filename stem is low_voltage_cmirror) +* ports: IBIAS1 IBIAS2 GND IOUT1 IOUT2 +* IBIAS1 and IBIAS2 are BOTH reference-current inputs (main + cascode bias); +* IOUT1/IOUT2 are the two mirrored output branches. +* +* Functional check: push a reference current into IBIAS1 and confirm both +* output branches sink ~the same current (1:1 mirror) when held at mid-rail. +* Swept reference so the ratio is checked across a range, not one point. +* +* run_cell_sim.py injects the .lib + DUT .include; stimulus + analysis ONLY. +* sky130 (1.8 V). NOTE: this cell wraps the FVF feedback loops, so the bias +* point is delicate -- calibrate the check bands against a golden ngspice run. +.param VDD=1.8 +.param IREF=10u +* This cell has TWO bias-current inputs: IBIAS1 (main mirror) and IBIAS2 +* (cascode level -- it is the Ib node of the second FVF and gates X4/X5). +* Both must be driven, or the output cascodes are starved and sink ~0. +* IBIAS2's value sets the cascode bias and may need a different magnitude +* than IREF -- calibrate against a golden run. +Iref 0 IBIAS1 DC {IREF} +Iref2 0 IBIAS2 DC {IREF} +* Outputs held near mid-rail through ammeters; i(vmX) is the sunk mirror current. +Vsup DD 0 DC 0.9 +Vm1 DD IOUT1 DC 0 +Vm2 DD IOUT2 DC 0 +* DUT: IBIAS1 IBIAS2 GND IOUT1 IOUT2 +* GND port tied straight to node 0 (no source -- a 0 V source here both ends +* on ground reads as a shorted VSRC and aborts the analysis). +X1 IBIAS1 IBIAS2 0 IOUT1 IOUT2 Low_voltage_current_mirror +.dc Iref 2u 20u 1u +.measure dc iout1_op FIND i(vm1) AT=10u +.measure dc iout2_op FIND i(vm2) AT=10u +.measure dc err1 param='(iout1_op - 10e-6)/10e-6' +.measure dc err2 param='(iout2_op - 10e-6)/10e-6' +.end diff --git a/tests/sim/testbenches/sky130/transmission_gate.spice b/tests/sim/testbenches/sky130/transmission_gate.spice new file mode 100644 index 00000000..21cf6f3e --- /dev/null +++ b/tests/sim/testbenches/sky130/transmission_gate.spice @@ -0,0 +1,38 @@ +* Testbench: CMOS transmission gate +* Transmission_Gate ports: VIN VSS VOUT VCC VGP VGN +* +* Functional check: with the gate ENABLED (VGN high, VGP low) the switch +* conducts from VIN to VOUT with a low, roughly rail-flat on-resistance. We +* pull a fixed load current out of VOUT and measure the IR drop across the +* closed switch at low / mid / high common mode; Ron = (V(VIN)-V(VOUT))/ILOAD. +* The complementary nfet+pfet keep Ron bounded across the whole input range -- +* that flatness is the TG's defining property and what we test here. +* +* run_cell_sim.py injects the .lib model line and .includes the DUT netlist, +* so this file is stimulus + analysis ONLY (no .lib / no .include here). +* +* sky130 (1.8 V) rails. To retarget gf180, bump VDD to 3.3 and move the AT= +* probe points / ILOAD accordingly. +.param VDD=1.8 +.param ILOAD=10u +* Rails feeding the DUT (nfet bulk = VSS, pfet bulk = VCC inside the subckt). +Vcc VCC 0 DC {VDD} +Vss VSS 0 DC 0 +* Gate drive: switch ON -> nmos gate high, pmos gate low. +Vgn VGN 0 DC {VDD} +Vgp VGP 0 DC 0 +* Swept input we pass through the closed switch. +Vin VIN 0 DC 0 +* Load: sink ILOAD out of VOUT so the closed switch must conduct it. +Iload VOUT 0 DC {ILOAD} +* DUT: VIN VSS VOUT VCC VGP VGN +X1 VIN VSS VOUT VCC VGP VGN Transmission_Gate +.dc Vin 0 {VDD} 0.02 +.measure dc vout_lo FIND v(VOUT) AT=0.3 +.measure dc vout_mid FIND v(VOUT) AT=0.9 +.measure dc vout_hi FIND v(VOUT) AT=1.5 +* Ron at each common mode. Literal 10e-6 must match ILOAD above. +.measure dc ron_lo param='(0.3 - vout_lo)/10e-6' +.measure dc ron_mid param='(0.9 - vout_mid)/10e-6' +.measure dc ron_hi param='(1.5 - vout_hi)/10e-6' +.end