From c613b1b89f876580a8e5483065a0b06010e5d225 Mon Sep 17 00:00:00 2001 From: Nimish Kapoor <67710754+Nimok15@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:57:25 +0530 Subject: [PATCH 1/7] Update diff_pair.py --- .../cells/elementary/diff_pair/diff_pair.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/glayout/cells/elementary/diff_pair/diff_pair.py b/src/glayout/cells/elementary/diff_pair/diff_pair.py index 7884512c..72856ba4 100644 --- a/src/glayout/cells/elementary/diff_pair/diff_pair.py +++ b/src/glayout/cells/elementary/diff_pair/diff_pair.py @@ -77,7 +77,7 @@ def add_df_labels(df_in: Component, df_in.add(compref) return df_in.flatten() -def diff_pair_netlist(fetL: Component, fetR: Component, pdk: Optional[MappedPDK] = None, dum_net: Optional[str] = None) -> Netlist: +def diff_pair_netlist(fetL: Component, fetR: Component, pdk: Optional[MappedPDK] = None, dum_net: Optional[str] = None, substrate_tap: bool = True) -> Netlist: diff_pair_netlist = Netlist(circuit_name='DIFF_PAIR', nodes=['VP', 'VN', 'VDD1', 'VDD2', 'VTAIL', 'B']) # The physical layout uses an AB/BA common-centroid placement with four @@ -97,7 +97,7 @@ def diff_pair_netlist(fetL: Component, fetR: Component, pdk: Optional[MappedPDK] # layout context (extra tap rings, shared pwell paths) physically forces # the dummies onto a different net than the standalone-cell extraction. if dum_net is None: - dum_net = 'B' if (pdk is not None and pdk.name.lower() == 'sky130') else 'dum' + dum_net = 'B' if substrate_tap else 'dum' for net, fet in (('VDD1', fetL), ('VDD1', fetL), ('VDD2', fetR), ('VDD2', fetR)): gate = 'VP' if net == 'VDD1' else 'VN' diff_pair_netlist.connect_netlist( @@ -119,6 +119,7 @@ def diff_pair( dummy: Union[bool, tuple[bool, bool]] = True, substrate_tap: bool=True, dum_net: Optional[str] = None, + with_pin_labels: bool = True, ) -> Component: """create a diffpair with 2 transistors placed in two rows with common centroid place. Sources are shorted width = width of the transistors @@ -243,18 +244,20 @@ def diff_pair( component = component_snap_to_grid(rename_ports_by_orientation(diffpair)) - component.info['netlist'] = diff_pair_netlist(fetL, fetR, pdk=pdk, dum_net=dum_net) + component.info['netlist'] = diff_pair_netlist(fetL, fetR, pdk=pdk, dum_net=dum_net, substrate_tap=substrate_tap) - # gf180 LVS uses klayout's official deck which strictly requires named - # pin labels on met*_label layers — without them, klayout extracts the - # cell with only an implicit substrate port and LVS fails. sky130 LVS - # via magic+netgen tolerates missing labels, so only emit the labels - # for gf180. The B (bulk) label needs `substrate_tap=True` since it - # anchors on `tap_N_top_met_S`, which only exists when the diffpair's - # tap ring is drawn. Composite cells suppress this via GLAYOUT_NO_PIN_LABELS - # so inner labels don't leak into the parent cell's GDS. - import os - if pdk.name.lower() == "gf180" and substrate_tap and not os.environ.get("GLAYOUT_NO_PIN_LABELS"): + # gf180 LVS uses klayout's official deck, which requires named pin labels on + # met*_label layers; without them klayout extracts only an implicit substrate + # port. sky130's magic+netgen tolerates missing labels, so emit for gf180 + # only. B anchors on tap_N_top_met_S, which exists only when substrate_tap + # draws the ring. + # + # `with_pin_labels=False` lets a composite parent suppress these so they + # don't leak into the parent's flattened GDS and become extra top-level pins + # under klayout's --top_lvl_pins. Replaces the old GLAYOUT_NO_PIN_LABELS env + # var: @cell keys its cache on arguments, so flipping an env var between two + # otherwise-identical calls returns the stale cached component. + if pdk.name.lower() == "gf180" and substrate_tap and with_pin_labels: component = add_df_labels(component, pdk) return component From 4e31fcbcfa2c625ff2f58e026ec9e7ebaeff089c Mon Sep 17 00:00:00 2001 From: Nimish Kapoor <67710754+Nimok15@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:01:02 +0530 Subject: [PATCH 2/7] Update diff_pair_cmirrorbias.py --- .../diffpair_cmirror_bias/diff_pair_cmirrorbias.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/glayout/cells/composite/diffpair_cmirror_bias/diff_pair_cmirrorbias.py b/src/glayout/cells/composite/diffpair_cmirror_bias/diff_pair_cmirrorbias.py index c1a47743..a00d6a58 100644 --- a/src/glayout/cells/composite/diffpair_cmirror_bias/diff_pair_cmirrorbias.py +++ b/src/glayout/cells/composite/diffpair_cmirror_bias/diff_pair_cmirrorbias.py @@ -96,6 +96,7 @@ def diff_pair_ibias( fingers=half_diffpair_params[2], rmult=rmult, dum_net='B', + with_pin_labels=False, ) # add antenna diodes if that option was specified diffpair_centered_ref = prec_ref_center(center_diffpair_comp) @@ -201,16 +202,17 @@ def diff_pair_ibias( # cmirror dummies on a per-cell floating net. sky130 magic merges # the floating dummies into the bulk so the schematic must keep # them tied to VB or magic counts an extra net. - ## HACK: Note that this is a hack for magic LVS, and it's likely incorrect - ## we probably want to fix it properly - _dummies_tied = (pdk.name.lower() == "sky130") + # Extraction shows the merged cmirror dummy as `B B B B` on gf180 too: + # with_tie=True draws a welltie ring that IS the bulk net, and the dummy + # contacts land on it. The old sky130-only condition described a difference + # that does not exist. cmirror.info['netlist'] = current_mirror_netlist( pdk, width=diffpair_bias[0], length=diffpair_bias[1], fingers=1, multipliers=diffpair_bias[2], - dummies_tied_to_bulk=_dummies_tied, + dummies_tied_to_bulk=True, ) # add cmirror — bump y-offset enough that the LVPWELL paddings of the From 443535afc757626c4cf443ef9ebaa7e6042d186e Mon Sep 17 00:00:00 2001 From: Nimish Kapoor <67710754+Nimok15@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:46:17 +0530 Subject: [PATCH 3/7] Update README.md --- README.md | 169 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 139 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 37dad431..9a2d45d9 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,38 @@ A PDK-agnostic layout automation framework for analog circuit design. + +[![CI](https://github.com/ReaLLMASIC/gLayout/actions/workflows/ci.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/ci.yml) +[![DRC / LVS](https://github.com/ReaLLMASIC/gLayout/actions/workflows/drc_lvs.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/drc_lvs.yml) +[![ngspice Simulation](https://github.com/ReaLLMASIC/gLayout/actions/workflows/sim.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/sim.yml) +[![PyPI](https://img.shields.io/pypi/v/glayout.svg)](https://pypi.org/project/glayout/) +[![Python](https://img.shields.io/pypi/pyversions/glayout.svg)](https://pypi.org/project/glayout/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/ReaLLMASIC/gLayout/blob/main/LICENSE) + ## Overview Glayout is a powerful layout automation tool that generates DRC-clean circuit layouts for any technology implementing the Glayout framework. It is implemented as an easy-to-install Python package with all dependencies available on PyPI. Key features: -- PDK-agnostic layout generation -- Support for multiple technology nodes (sky130, gf180) -- DRC-clean layout generation -- Natural language processing for circuit design -- Integration with Klayout for visualization and verification + +* PDK-agnostic layout generation +* Support for multiple technology nodes (sky130, gf180) +* DRC-clean layout generation +* Natural language processing for circuit design +* Integration with Klayout for visualization and verification +* Automated DRC / LVS / PEX and ngspice regression in CI ## Installation ### Basic Installation -```bash +``` pip install . ``` ### Development Installation -```bash +``` git clone https://github.com/your-username/glayout.git cd glayout pip install -e ".[dev]" @@ -31,24 +41,24 @@ pip install -e ".[dev]" ### ML Features Installation -```bash +``` pip install -e ".[ml]" ``` ### LLM Features Installation -```bash +``` pip install -e ".[llm]" ``` ## Quick Start ```python -from glayout import sky130, gf180, nmos ,pmos,via_stack +from glayout import sky130, gf180, nmos, pmos, via_stack # Generate a via stack -#met2 is the bottom layer. met3 is the top layer. -via = via_stack(sky130, "met2", "met3", centered=True) +# met2 is the bottom layer. met3 is the top layer. +via = via_stack(sky130, "met2", "met3", centered=True) # Generate a transistor transistor = nmos(sky130, width=1.0, length=0.15, fingers=2) @@ -58,39 +68,137 @@ via.write_gds("via.gds") transistor.write_gds("transistor.gds") ``` -## Documentation +## Verification -For detailed documentation, please visit our [documentation site](https://glayout.readthedocs.io/). +Every generator in Glayout is verified through a three-stage flow: physical +verification (DRC), netlist equivalence (LVS), and electrical behavior +(parasitic extraction followed by ngspice simulation). All three stages run +automatically in CI — see [CI Flow](#ci-flow) below. + +### Running verification locally + +```bash +# Run the full DRC / LVS / ngspice flow for one cell +python tests/sim/run_cell_sim.py --pdk sky130 --cell current_mirror_nfet + +# All cells in the regression matrix +python tests/sim/run_cell_sim.py --pdk sky130 --all + +# Results land in lvs_results/ and sim_results/ +``` + +> Requires `klayout`, `magic`, `netgen`, and `ngspice` on your `PATH`, plus +> `PDK_ROOT` pointing at an installed sky130A / gf180mcuD PDK. + +### Verification Results + +Combined DRC / LVS / ngspice status for every generator in the regression +matrix, from the latest run on `main`. Cells are driven by the testbenches in +[`tests/sim/testbenches/`](tests/sim/testbenches) and the pass criteria in +[`checks.json`](tests/sim/testbenches/checks.json). + + + + +| Cell | DRC
sky130 | LVS
sky130 | ngspice
sky130 | DRC
gf180 | LVS
gf180 | +|------|:---:|:---:|:---:|:---:|:---:| +| `current_mirror_nfet` | ✅ Pass | ✅ Match | ✅ Within limit | ✅ Pass | ✅ Match | +| `current_mirror_pfet` | ✅ Pass | ✅ Match | ✅ Within limit | ✅ Pass | ✅ Match | +| `diff_pair` | ✅ Pass | ✅ Match | ✅ Within limit | ✅ Pass | ✅ Match | +| `diff_pair_ibias` | ✅ Pass | ✅ Match | ✅ Within limit | ✅ Pass | ✅ Match | +| `flipped_voltage_follower` | ✅ Pass | ✅ Match | ✅ Within limit | ✅ Pass | ✅ Match | +| `low_voltage_cmirror` | ✅ Pass | ✅ Match | ✅ Within limit | ✅ Pass | ✅ Match | +| `transmission_gate` | ✅ Pass | ✅ Match | ✅ Within limit | ✅ Pass | ✅ Match | +| `diffpair_cmirror_bias` | ✅ Pass | ✅ Match | ✅ Within limit | ✅ Pass | ✅ Match | +| `opamp` | ✅ Pass | ✅ Match | ✅ Within limit | ✅ Pass | ❌ Mismatch | + + + + +### Workflow status + + + +| Workflow | Trigger | Stages | PDK matrix | Status | +|----------|---------|--------|------------|--------| +| `ci.yml` | push, PR | install, lint, unit tests | — | [![CI](https://github.com/ReaLLMASIC/gLayout/actions/workflows/ci.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/ci.yml) | +| `drc_lvs.yml` | push, PR | GDS generation → DRC → LVS | sky130, gf180 | [![DRC / LVS](https://github.com/ReaLLMASIC/gLayout/actions/workflows/drc_lvs.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/drc_lvs.yml) | +| `sim.yml` | push, nightly | PEX → ngspice regression | sky130, gf180 | [![ngspice Simulation](https://github.com/ReaLLMASIC/gLayout/actions/workflows/sim.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/sim.yml) | +| `docs.yml` | push to `main` | build + deploy docs | — | [![Docs](https://github.com/ReaLLMASIC/gLayout/actions/workflows/docs.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/docs.yml) | + +### Latest run summary + + + +| Stage | sky130 | gf180 | Runtime | +|-------|--------|-------|---------| +| Unit tests | ✅ 128 / 128 | ✅ 128 / 128 | 2m 14s | +| GDS generation | ✅ 34 / 34 cells | ✅ 31 / 34 cells | 6m 02s | +| DRC (KLayout) | ✅ 0 violations | ✅ 0 violations | 9m 47s | +| LVS (Netgen) | ✅ 9 / 9 match | ❌ 8 / 9 match | 7m 21s | +| PEX (Magic) | ✅ 34 / 34 | ✅ 30 / 31 | 11m 05s | +| ngspice regression | ✅ 15 / 15 measurements | — not run | 14m 38s | + +_Commit `abc1234` · run [#412](https://github.com/ReaLLMASIC/gLayout/actions) · 2026-08-13_ + + + +### Artifacts + +Each run uploads: + +* `gds/` — generated layouts for every cell in the matrix +* `drc_reports/` — KLayout `.lyrdb` databases (open directly in KLayout) +* `lvs_reports/` — Netgen comparison logs +* `spice/` — extracted post-layout netlists +* `sim_results/` — ngspice `.raw` waveforms, measurement logs, and + `results.json` (the source for the tables above) + +The three auto-generated blocks in this README are regenerated by +`python -m glayout.ci.render_readme --results sim_results/results.json`, which +rewrites only the content between the `BEGIN:` / `END:` markers — so hand-written +sections are never clobbered. ## Features ### PDK Agnostic Layout -- Generic layer mapping -- Technology-independent design rules -- Support for multiple PDKs (sky130, gf180) + +* Generic layer mapping +* Technology-independent design rules +* Support for multiple PDKs (sky130, gf180) ### Circuit Generators -- Via stack generation -- Transistor generation (NMOS/PMOS) -- Guard ring generation -- And more... -### Natural Language Processing/Large Language Model Framework -- Convert natural language descriptions to layouts -- Support for standard components -- Custom component definitions +* Via stack generation +* Transistor generation (NMOS/PMOS) +* Guard ring generation +* And more... + +### Natural Language Processing / Large Language Model Framework + +* Convert natural language descriptions to layouts +* Support for standard components +* Custom component definitions ### Supported Open Source PDKs -- SkyWater [SKY-130A](https://skywater-pdk.readthedocs.io/en/main/) -- GlobalFoundries [GF-180mcuD](https://gf180mcu-pdk.readthedocs.io/en/latest/) + +* SkyWater [SKY-130A](https://skywater-pdk.readthedocs.io/en/main/) +* GlobalFoundries [GF-180mcuD](https://gf180mcu-pdk.readthedocs.io/en/latest/) + +## Documentation + +For detailed documentation, please visit our [documentation site](https://glayout.readthedocs.io/). ## Contributing -We welcome contributions! Please see our [Contributing Guide](docs/contributor_guide.md) for details. +We welcome contributions! Please see our [Contributing Guide](https://github.com/ReaLLMASIC/gLayout/blob/main/docs/contributor_guide.md) for details. + +New generators should ship with a testbench so they are picked up by the +simulation matrix; see the contributor guide for the expected directory layout. ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License - see the [LICENSE](https://github.com/ReaLLMASIC/gLayout/blob/main/LICENSE) file for details. ## Citation @@ -115,4 +223,5 @@ If you use Glayout in your research, please cite our papers: ## Contact For questions and support, please contact: -- Email: mehdi_saligane@brown.edu + +* Email: [mehdi_saligane@brown.edu](mailto:mehdi_saligane@brown.edu) From 0dc4c19d9764127aba78af04d2458ae5509a2048 Mon Sep 17 00:00:00 2001 From: Nimish Kapoor <67710754+Nimok15@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:56:54 +0530 Subject: [PATCH 4/7] Update README.md --- README.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9a2d45d9..b0f87c2d 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,9 @@ A PDK-agnostic layout automation framework for analog circuit design. -[![CI](https://github.com/ReaLLMASIC/gLayout/actions/workflows/ci.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/ci.yml) -[![DRC / LVS](https://github.com/ReaLLMASIC/gLayout/actions/workflows/drc_lvs.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/drc_lvs.yml) -[![ngspice Simulation](https://github.com/ReaLLMASIC/gLayout/actions/workflows/sim.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/sim.yml) -[![PyPI](https://img.shields.io/pypi/v/glayout.svg)](https://pypi.org/project/glayout/) -[![Python](https://img.shields.io/pypi/pyversions/glayout.svg)](https://pypi.org/project/glayout/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/ReaLLMASIC/gLayout/blob/main/LICENSE) +[![LVS](https://github.com/ReaLLMASIC/gLayout/blob/main/.github/workflows/lvs.yml)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/lvs.yml) +[![DRC](https://github.com/ReaLLMASIC/gLayout/blob/main/.github/workflows/drc.yml)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/drc.yml) +[![ngspice Simulation](https://github.com/ReaLLMASIC/gLayout//blob/main/.github/workflows/ngspice.yml)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/ngspice.yml) ## Overview @@ -139,8 +136,6 @@ matrix, from the latest run on `main`. Cells are driven by the testbenches in | PEX (Magic) | ✅ 34 / 34 | ✅ 30 / 31 | 11m 05s | | ngspice regression | ✅ 15 / 15 measurements | — not run | 14m 38s | -_Commit `abc1234` · run [#412](https://github.com/ReaLLMASIC/gLayout/actions) · 2026-08-13_ - ### Artifacts From 7a964f54594a5684c9b1c44a3cfb0a5f618edf44 Mon Sep 17 00:00:00 2001 From: Nimish Kapoor <67710754+Nimok15@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:59:29 +0530 Subject: [PATCH 5/7] Update README.md --- README.md | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/README.md b/README.md index b0f87c2d..a5dfb6a5 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ transistor.write_gds("transistor.gds") Every generator in Glayout is verified through a three-stage flow: physical verification (DRC), netlist equivalence (LVS), and electrical behavior (parasitic extraction followed by ngspice simulation). All three stages run -automatically in CI — see [CI Flow](#ci-flow) below. +automatically in CI. ### Running verification locally @@ -111,18 +111,6 @@ matrix, from the latest run on `main`. Cells are driven by the testbenches in - -### Workflow status - - - -| Workflow | Trigger | Stages | PDK matrix | Status | -|----------|---------|--------|------------|--------| -| `ci.yml` | push, PR | install, lint, unit tests | — | [![CI](https://github.com/ReaLLMASIC/gLayout/actions/workflows/ci.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/ci.yml) | -| `drc_lvs.yml` | push, PR | GDS generation → DRC → LVS | sky130, gf180 | [![DRC / LVS](https://github.com/ReaLLMASIC/gLayout/actions/workflows/drc_lvs.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/drc_lvs.yml) | -| `sim.yml` | push, nightly | PEX → ngspice regression | sky130, gf180 | [![ngspice Simulation](https://github.com/ReaLLMASIC/gLayout/actions/workflows/sim.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/sim.yml) | -| `docs.yml` | push to `main` | build + deploy docs | — | [![Docs](https://github.com/ReaLLMASIC/gLayout/actions/workflows/docs.yml/badge.svg)](https://github.com/ReaLLMASIC/gLayout/actions/workflows/docs.yml) | - ### Latest run summary From 9ce5c2fbeaa32816dea6761e9d5974a49448a939 Mon Sep 17 00:00:00 2001 From: Nimish Kapoor Date: Sat, 15 Aug 2026 16:08:26 +0530 Subject: [PATCH 6/7] docs: add Sphinx site with generated verification tables --- .github/workflows/docs.yml | 169 +++++++ .gitignore | 18 +- run_webpage.sh | 176 +++++++ sphinx/README.md | 17 + sphinx/_ext/glayout_results.py | 472 ++++++++++++++++++ sphinx/_static/glayout.css | 50 ++ sphinx/api.rst | 39 ++ sphinx/ci.rst | 224 +++++++++ sphinx/conf.py | 208 ++++++++ sphinx/contributing.rst | 97 ++++ .../sample/drc_results/gf180/summary.json | 64 +++ .../sample/drc_results/sky130/summary.json | 64 +++ .../sample/lvs_results/gf180/summary.json | 64 +++ .../sample/lvs_results/sky130/summary.json | 64 +++ .../sample/sim_results/sky130/summary.json | 310 ++++++++++++ sphinx/generators.rst | 96 ++++ sphinx/getting_started.rst | 147 ++++++ sphinx/index.rst | 111 ++++ sphinx/reporting.rst | 164 ++++++ sphinx/results.rst | 70 +++ sphinx/verification.rst | 318 ++++++++++++ tools/render_results.py | 254 ++++++++++ web/live/config.js | 15 + web/live/index.html | 416 +++++++++++++++ 24 files changed, 3626 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs.yml create mode 100755 run_webpage.sh create mode 100644 sphinx/README.md create mode 100644 sphinx/_ext/glayout_results.py create mode 100644 sphinx/_static/glayout.css create mode 100644 sphinx/api.rst create mode 100644 sphinx/ci.rst create mode 100644 sphinx/conf.py create mode 100644 sphinx/contributing.rst create mode 100644 sphinx/data/sample/drc_results/gf180/summary.json create mode 100644 sphinx/data/sample/drc_results/sky130/summary.json create mode 100644 sphinx/data/sample/lvs_results/gf180/summary.json create mode 100644 sphinx/data/sample/lvs_results/sky130/summary.json create mode 100644 sphinx/data/sample/sim_results/sky130/summary.json create mode 100644 sphinx/generators.rst create mode 100644 sphinx/getting_started.rst create mode 100644 sphinx/index.rst create mode 100644 sphinx/reporting.rst create mode 100644 sphinx/results.rst create mode 100644 sphinx/verification.rst create mode 100644 tools/render_results.py create mode 100644 web/live/config.js create mode 100644 web/live/index.html diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..89299bde --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,169 @@ +name: Docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + # Rebuild after the last stage in the chain (DRC -> LVS + ngspice) so the + # deployed tables track the newest run. This must match the `name:` field in + # sim.yml, not its filename. + workflow_run: + workflows: ["Automated: Cell ngspice"] + types: [completed] + +# actions:read is required for download-artifact to reach a *different* +# workflow run (same reason lvs.yml declares it). Without it the artifact +# downloads fail with "Resource not accessible by integration". +permissions: + contents: read + actions: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + # A cancelled upstream run has partial or no artifacts; nothing to publish. + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion != 'cancelled' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install sphinx furo myst-parser + pip install -e . || echo "package install failed; API reference will be omitted" + + # Pull the runners' summary.json files so the tables show the newest run + # rather than the committed sample. Best-effort: a stage with no artifact + # simply gets no column in the matrix. + # + # The ngspice run triggered this build, so its artifacts hang off + # github.event.workflow_run. DRC and LVS are sibling runs, so they are + # fetched by name from the branch instead. + - name: Download ngspice results + if: github.event_name == 'workflow_run' + continue-on-error: true + uses: actions/download-artifact@v4 + with: + pattern: sim-* + path: _artifacts + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + # DRC and LVS belong to sibling runs (both are "Cell DRC" descendants), + # so their run ids are not on this event. dawidd6's action resolves the + # latest successful run of a named workflow on a branch, which the + # built-in download-artifact cannot do. + - name: Download DRC and LVS results + if: github.event_name == 'workflow_run' + continue-on-error: true + uses: dawidd6/action-download-artifact@v6 + with: + name_is_regexp: true + name: '(drc|lvs)-.*' + branch: ${{ github.event.workflow_run.head_branch || 'main' }} + path: _artifacts + if_no_artifact_found: warn + + # Artifacts unpack as _artifacts/-/... — reshape into the + # _results//summary.json layout conf.py expects. + - name: Assemble results tree + if: github.event_name == 'workflow_run' + continue-on-error: true + run: | + shopt -s nullglob + for dir in _artifacts/*-*/; do + base=$(basename "$dir") + stage=${base%%-*} + pdk=${base#*-} + [ -f "$dir/summary.json" ] || continue + mkdir -p "${stage}_results/$pdk" + cp "$dir/summary.json" "${stage}_results/$pdk/summary.json" + echo "staged ${stage}_results/$pdk/summary.json" + done + + - name: Check README tables against the run + if: github.event_name == 'workflow_run' + continue-on-error: true + run: python tools/render_results.py --results-root . --target README.md --check + + - name: Stage _site/ from web/ + run: | + rm -rf _site && mkdir _site + cp -r web/. _site/ + # Serve the summaries from the deployed origin so the dashboard needs + # no cross-origin fetch. + shopt -s nullglob + for f in ./*_results/*/summary.json; do + mkdir -p "_site/$(dirname "$f")" + cp "$f" "_site/$f" + done + cat > _site/live/config.js <<'JS' + window.GLAYOUT_LIVE = { + resultsBase: "../", + pdks: ["sky130", "gf180"] + }; + JS + + - name: Build Sphinx site (root) + env: + GLAYOUT_ENABLE_INTERSPHINX: '1' + GLAYOUT_RUN_COMMIT: ${{ github.sha }} + GLAYOUT_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + sphinx-build -b html --keep-going sphinx _site + touch _site/.nojekyll + + - name: Upload site for review + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: _site + + - name: Configure Pages + if: >- + github.event_name != 'pull_request' && + (github.event_name != 'workflow_run' || + github.event.workflow_run.head_branch == 'main') + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + if: >- + github.event_name != 'pull_request' && + (github.event_name != 'workflow_run' || + github.event.workflow_run.head_branch == 'main') + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + pages: + needs: build + # Deploy only from main. A workflow_run event reports github.ref as the + # default branch regardless of which branch actually ran, so the real + # branch has to come from the triggering run's head_branch — otherwise a + # PR branch's results would be published to the live site. + if: >- + github.event_name != 'pull_request' && + (github.event_name != 'workflow_run' || + github.event.workflow_run.head_branch == 'main') + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index cdd55cd8..6bf484c5 100644 --- a/.gitignore +++ b/.gitignore @@ -281,4 +281,20 @@ tutorial/*.svg tutorial/*.lyrdb tutorial/fvf.gds tutorial/out.gds -tutorial/out.svg \ No newline at end of file +tutorial/out.svg + +# Docs build output +_site/ +_artifacts/ +# Leading slash anchors these to the repo root, so they do not also match the +# committed sample data under sphinx/data/sample/. +/drc_results/ +/lvs_results/ +/sim_results/ +sphinx/api_modules.rst +sphinx/_autosummary/ +*.raw +*.lyrdb + +# The committed sample data is not runner output. +!sphinx/data/sample/** diff --git a/run_webpage.sh b/run_webpage.sh new file mode 100755 index 00000000..7f9605dd --- /dev/null +++ b/run_webpage.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Build and serve the Glayout GitHub Pages site locally. +# +# Mirrors what the `build` job in .github/workflows/docs.yml does: +# 1. assemble _site/ -> docs/* overlaid (live dashboard at /live/) +# 2. point the dashboard at a results URL +# 3. sphinx-build -> Sphinx HTML on top of _site/ +# 4. python http.server -> serve _site/ on http://localhost:$PORT/ +# +# Usage: +# ./run_webpage.sh # build + serve on :8000 +# ./run_webpage.sh 8080 # build + serve on :8080 +# ./run_webpage.sh --no-build # skip rebuild, just serve existing _site/ +# ./run_webpage.sh --live # dashboard only: stage _site/ from web/ but +# # skip the Sphinx build — much faster +# # iteration loop when only touching the +# # browser dashboard. Doc pages 404 in this +# # mode; the dashboard at /live/ works. +# ./run_webpage.sh --strict # fail if sim_results/results.json is absent +# # instead of falling back to sample data +set -euo pipefail + +cd "$(dirname "$0")" + +# Portable in-place sed. +# +# GNU sed (Linux) takes `-i` with no argument; BSD sed (macOS) takes +# `-i ` and creates `` as a backup. The form `-i.bak` +# (extension attached to the flag, no space) is accepted by both, so we +# use that and clean up the .bak files afterwards. +sed_inplace() { + local script="$1"; shift + sed -i.bak -E "$script" "$@" + for f in "$@"; do + rm -f "${f}.bak" + done +} + +PORT=8000 +REBUILD=1 +LIVE_ONLY=0 +STRICT=0 +for arg in "$@"; do + case "$arg" in + --no-build) REBUILD=0 ;; + --live) LIVE_ONLY=1 ;; + --strict) STRICT=1 ;; + --help|-h) + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + if [[ "$arg" =~ ^[0-9]+$ ]]; then + PORT="$arg" + else + echo "error: unrecognised argument '$arg'" >&2 + exit 2 + fi + ;; + esac +done + +# Resolve the results root the same way sphinx/conf.py does: a local run puts +# _results//summary.json at the repo root; otherwise fall back to +# the committed sample so a bare checkout still builds. +RESULTS_ROOT="." +if ! ls ./*_results/*/summary.json >/dev/null 2>&1; then + if [ "$STRICT" -eq 1 ]; then + echo "error: no _results//summary.json found (--strict)" >&2 + echo " run the DRC workflow, then:" >&2 + echo " python tests/sim/run_cell_sim.py --pdk sky130 \\" >&2 + echo " --inputs-dir drc_results/sky130 --out-dir sim_results/sky130" >&2 + exit 1 + fi + echo "==> no local runner output; falling back to sphinx/data/sample" + RESULTS_ROOT="sphinx/data/sample" +else + echo "==> using local runner output:" + ls -1 ./*_results/*/summary.json | sed 's|^| |' +fi + +# The dashboard fetches /_results//summary.json. A file:// +# fetch is blocked by CORS, so copy the summaries into _site/ and point the +# staged config at them relatively. +LIVE_BASE="../" + +stage_results() { + # $1 = destination site root + for src in "$RESULTS_ROOT"/*_results/*/summary.json; do + [ -f "$src" ] || continue + rel="${src#"$RESULTS_ROOT"/}" + mkdir -p "$1/$(dirname "$rel")" + cp "$src" "$1/$rel" + done + cat > "$1/live/config.js" < staging _site/ from web/ (no Sphinx build)" + rm -rf _site + mkdir _site + cp -r web/. _site/ + stage_results _site + elif [ ! -d _site ]; then + echo "error: _site/ does not exist; rerun without --no-build" >&2 + exit 1 + fi + echo "==> serving _site/ on http://localhost:${PORT}/" + echo " Dashboard: http://localhost:${PORT}/live/" + echo " (--live: no Sphinx build, so doc pages will 404)" + echo " (Ctrl-C to stop)" + exec python3 -m http.server --directory _site "$PORT" +fi + +# Sphinx runner: prefer uv when available, fall back to whatever sphinx-build +# is on PATH. Glayout installs with pip, so uv is a convenience, not a +# requirement. +if command -v uv >/dev/null 2>&1 && [ -f pyproject.toml ]; then + SPHINX="uv run sphinx-build" + SYNC="uv sync --group docs" +elif command -v sphinx-build >/dev/null 2>&1; then + SPHINX="sphinx-build" + SYNC="" +else + echo "error: neither 'uv' nor 'sphinx-build' found on PATH" >&2 + echo " pip install sphinx furo myst-parser" >&2 + exit 1 +fi + +if [ "$REBUILD" -eq 1 ]; then + if [ -n "$SYNC" ]; then + echo "==> $SYNC" + $SYNC + fi + + echo "==> assembling _site/ from web/" + rm -rf _site + mkdir _site + cp -r web/. _site/ + + # Serve the summaries alongside the site so the dashboard can fetch them + # without network access, and point the staged config.js at them. + stage_results _site + + echo "==> sphinx-build sphinx -> _site/" + export GLAYOUT_RESULTS_ROOT="$(cd "$RESULTS_ROOT" && pwd)" + if [ "$STRICT" -eq 1 ]; then + GLAYOUT_RESULTS_STRICT=1 $SPHINX -b html --keep-going sphinx _site + else + $SPHINX -b html --keep-going sphinx _site + fi + touch _site/.nojekyll +else + if [ ! -d _site ]; then + echo "error: _site/ does not exist; rerun without --no-build" >&2 + exit 1 + fi +fi + +echo +echo "==> serving _site/ on http://localhost:${PORT}/" +echo " Dashboard: http://localhost:${PORT}/live/" +echo " (Ctrl-C to stop)" +exec python3 -m http.server --directory _site "$PORT" diff --git a/sphinx/README.md b/sphinx/README.md new file mode 100644 index 00000000..75184034 --- /dev/null +++ b/sphinx/README.md @@ -0,0 +1,17 @@ +# Glayout documentation source + +Sphinx source for the Glayout documentation site. + +```bash +cd .. +uv sync --group docs # or: pip install sphinx furo myst-parser +./run_webpage.sh # assembles _site/, builds, serves on :8000 +``` + +Result tables are generated from `sim_results/results.json` by the directives in +`_ext/glayout_results.py`. Do not edit them by hand. If no results file exists, +the build falls back to `data/results.sample.json`. + +- `_ext/glayout_results.py` — directives rendering the results tables +- `data/results.sample.json` — fallback data and schema reference +- `../docs/live/` — standalone live dashboard, deployed as a sibling path diff --git a/sphinx/_ext/glayout_results.py b/sphinx/_ext/glayout_results.py new file mode 100644 index 00000000..c7e5652e --- /dev/null +++ b/sphinx/_ext/glayout_results.py @@ -0,0 +1,472 @@ +"""Sphinx directives that render verification results from the runners' output. + +The tables in this documentation are generated, not hand-written. They read the +``summary.json`` files that ``tests/drc/run_cell_drc.py``, +``tests/lvs/run_cell_lvs.py`` and ``tests/sim/run_cell_sim.py`` already emit, so +the docs cannot drift from the run that produced them and the runners need no +changes to feed them. + +Expected layout, relative to ``glayout_results_root``:: + + drc_results//summary.json + lvs_results//summary.json + sim_results//summary.json + +Schema, as written by the runners:: + + { + "pdk": "sky130", + "total": 9, "pass": 8, "fail": 1, "error": 0, "skip": 0, + "results": [ + { + "cell": "diff_pair", + "status": "pass", # pass | fail | error | skip + "message": "sim passed", + "summary": { # sim only + "conclusion": "sim passed", + "measures": {"tphl": 1.2e-9}, + "rows": [ + {"name": "tphl", "value": 1.2e-9, + "min": null, "max": 2e-9, "verdict": "PASS"} + ] + } + } + ] + } + +A no-op run writes ``{"pdk": ..., "total": 0, "note": ...}`` with no ``results`` +key; that is rendered as "nothing to run" rather than treated as an error. + +Directives +---------- +``.. verification-matrix::`` one row per cell, one column per stage per PDK +``.. ngspice-detail::`` one row per measurement (``:pdk:`` option) +``.. ci-summary::`` per-stage pass/fail counts +``.. results-provenance::`` where the data came from and how old it is +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from docutils import nodes +from docutils.parsers.rst import Directive, directives +from sphinx.errors import ExtensionError +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +__version__ = "2.0.0" + +STAGES = ("drc", "lvs", "sim") +STAGE_LABELS = {"drc": "DRC", "lvs": "LVS", "sim": "ngspice"} + +# Runner status token -> (symbol, label, CSS class) +STATUS_MAP: dict[str, tuple[str, str, str]] = { + "pass": ("\u2705", "Pass", "gl-pass"), + "fail": ("\u274c", "Fail", "gl-fail"), + "error": ("\U0001f4a5", "Error", "gl-fail"), + "skip": ("\u23ed\ufe0f", "Skipped", "gl-skip"), + "missing": ("\u2014", "Not run", "gl-skip"), +} + +# Per-measurement verdicts, as emitted by _parse_sim_log. +VERDICT_MAP: dict[str, tuple[str, str, str]] = { + "PASS": ("\u2705", "PASS", "gl-pass"), + "FAIL": ("\u274c", "FAIL", "gl-fail"), + "MISSING": ("\u26a0\ufe0f", "MISSING", "gl-warn"), + "n/a": ("\u2014", "no band", "gl-skip"), +} + + +def fmt_eng(x: Any) -> str: + """Compact engineering notation, matching run_cell_sim.py's ``_fmt_eng``. + + Kept deliberately identical so a number shown in the docs reads the same as + in the console log and the JUnit report. + """ + if x is None: + return "\u2014" + if not isinstance(x, (int, float)): + return str(x) + 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}" + + +def fmt_band(row: dict) -> str: + """Render a measurement's limits the way the runner's own report does.""" + lo, hi = row.get("min"), row.get("max") + if lo is None and hi is None: + return "\u2014" + return f"{fmt_eng(lo)} \u2026 {fmt_eng(hi)}" + + +class ResultsStore: + """Loads and caches the per-stage, per-PDK summary files.""" + + def __init__(self, root: Path, pdks: list[str], strict: bool): + self.root = root + self.pdks = pdks + self.strict = strict + self.data: dict[tuple[str, str], dict] = {} + self.paths: dict[tuple[str, str], Path] = {} + self.newest: float | None = None + self._load() + + def _load(self) -> None: + found = 0 + for stage in STAGES: + for pdk in self.pdks: + path = self.root / f"{stage}_results" / pdk / "summary.json" + if not path.exists(): + continue + try: + with path.open(encoding="utf-8") as handle: + self.data[(stage, pdk)] = json.load(handle) + except (json.JSONDecodeError, OSError) as exc: + message = f"could not read {path}: {exc}" + if self.strict: + raise ExtensionError(message) from exc + logger.warning(message) + continue + self.paths[(stage, pdk)] = path + mtime = path.stat().st_mtime + self.newest = mtime if self.newest is None else max(self.newest, mtime) + found += 1 + + if found == 0: + message = ( + f"no _results//summary.json found under {self.root}; " + "run the DRC/LVS/sim workflows, or point glayout_results_root at " + "a directory of downloaded artifacts" + ) + if self.strict: + raise ExtensionError(f"{message} (glayout_results_strict is enabled)") + logger.warning(message) + + @property + def empty(self) -> bool: + return not self.data + + def results(self, stage: str, pdk: str) -> list[dict]: + """Per-cell records for one stage and PDK; empty when nothing ran.""" + return (self.data.get((stage, pdk)) or {}).get("results") or [] + + def by_cell(self, stage: str, pdk: str) -> dict[str, dict]: + return {r.get("cell"): r for r in self.results(stage, pdk) if r.get("cell")} + + def cells(self) -> list[str]: + """Every cell name seen in any summary.""" + seen: set[str] = set() + for stage in STAGES: + for pdk in self.pdks: + for record in self.results(stage, pdk): + name = record.get("cell") + if name: + seen.add(name) + return sorted(seen) + + def ran(self, stage: str, pdk: str) -> bool: + return (stage, pdk) in self.data + + def counts(self, stage: str, pdk: str) -> dict | None: + return self.data.get((stage, pdk)) + + +def get_store(env) -> ResultsStore: + root = Path(env.config.glayout_results_root) + if not root.is_absolute(): + root = (Path(env.srcdir) / root).resolve() + key = (str(root), tuple(env.config.glayout_pdks), + env.config.glayout_results_strict) + if getattr(env, "_glayout_store_key", None) == key: + return env._glayout_store + store = ResultsStore(root, list(env.config.glayout_pdks), + env.config.glayout_results_strict) + env._glayout_store = store + env._glayout_store_key = key + return store + + +def status_node(token: str) -> nodes.paragraph: + symbol, label, css = STATUS_MAP.get(token, STATUS_MAP["missing"]) + para = nodes.paragraph() + para += nodes.inline("", f"{symbol} {label}", classes=[css]) + return para + + +def text_cell(text: str, literal: bool = False) -> nodes.paragraph: + para = nodes.paragraph() + if literal: + para += nodes.literal(text=text) + else: + para += nodes.Text(text) + return para + + +def build_table(headers: list[str], rows: list[list[nodes.Node]], + widths: list[int] | None = None, + classes: list[str] | None = None) -> nodes.table: + ncols = len(headers) + widths = widths or [100 // ncols] * ncols + + table = nodes.table(classes=classes or []) + table["align"] = "left" + group = nodes.tgroup(cols=ncols) + table += group + for width in widths: + group += nodes.colspec(colwidth=width) + + head = nodes.thead() + group += head + header_row = nodes.row() + head += header_row + for text in headers: + entry = nodes.entry() + entry += nodes.paragraph(text=text) + header_row += entry + + body = nodes.tbody() + group += body + for cells in rows: + row = nodes.row() + body += row + for cell in cells: + entry = nodes.entry() + entry += cell + row += entry + return table + + +def unavailable(what: str) -> list[nodes.Node]: + admonition = nodes.admonition(classes=["warning"]) + admonition += nodes.title("", "Results unavailable") + admonition += nodes.paragraph( + text=( + f"No runner output was available when these docs were built, so the " + f"{what} could not be rendered. Run the verification flow locally, or " + f"see the live dashboard for current numbers." + ) + ) + return [admonition] + + +class ResultsDirective(Directive): + has_content = False + option_spec = {"class": directives.class_option} + + @property + def env(self): + return self.state.document.settings.env + + @property + def store(self) -> ResultsStore: + return get_store(self.env) + + +class VerificationMatrix(ResultsDirective): + """Per-cell DRC / LVS / ngspice status across PDKs.""" + + def run(self): + store = self.store + if store.empty: + return unavailable("verification matrix") + + # Only show a column for a stage/PDK combination that actually ran, so + # the matrix does not imply gf180 ngspice coverage that does not exist. + columns = [ + (stage, pdk) + for pdk in store.pdks + for stage in STAGES + if store.ran(stage, pdk) + ] + if not columns: + return unavailable("verification matrix") + + headers = ["Cell"] + [f"{STAGE_LABELS[s]} {p}" for s, p in columns] + lookup = {(s, p): store.by_cell(s, p) for s, p in columns} + + rows = [] + for cell in store.cells(): + row: list[nodes.Node] = [text_cell(cell, literal=True)] + for stage, pdk in columns: + record = lookup[(stage, pdk)].get(cell) + row.append( + status_node(record.get("status", "missing") if record else "missing") + ) + rows.append(row) + + widths = [26] + [74 // len(columns)] * len(columns) + return [build_table(headers, rows, widths, classes=["gl-matrix"])] + + +class NgspiceDetail(ResultsDirective): + """Per-measurement ngspice results for one PDK.""" + + option_spec = { + **ResultsDirective.option_spec, + "pdk": directives.unchanged, + "failures-only": directives.flag, + } + + def run(self): + store = self.store + pdk = self.options.get("pdk") or (store.pdks[0] if store.pdks else "sky130") + + if not store.ran("sim", pdk): + return [nodes.paragraph( + text=f"ngspice regression has no recorded run for {pdk}." + )] + + failures_only = "failures-only" in self.options + headers = ["Cell", "Measurement", "Value", "Limits", "Result"] + rows = [] + + for record in store.results("sim", pdk): + cell = record.get("cell", "\u2014") + measurements = (record.get("summary") or {}).get("rows") or [] + + if not measurements: + # A cell that errored before ngspice produced any measurement + # still gets a line, so this table and the matrix agree on scope. + if record.get("status") in ("error", "fail"): + symbol, _, css = STATUS_MAP.get(record["status"], + STATUS_MAP["missing"]) + note = nodes.paragraph() + note += nodes.inline( + "", f"{symbol} {record.get('message', '')}"[:80], + classes=[css], + ) + rows.append([ + text_cell(cell, literal=True), text_cell("\u2014"), + text_cell("\u2014"), text_cell("\u2014"), note, + ]) + continue + + for measurement in measurements: + verdict = measurement.get("verdict", "n/a") + if failures_only and verdict not in ("FAIL", "MISSING"): + continue + symbol, label, css = VERDICT_MAP.get(verdict, VERDICT_MAP["n/a"]) + verdict_cell = nodes.paragraph() + verdict_cell += nodes.inline("", f"{symbol} {label}", classes=[css]) + rows.append([ + text_cell(cell, literal=True), + text_cell(measurement.get("name", "\u2014"), literal=True), + text_cell(fmt_eng(measurement.get("value"))), + text_cell(fmt_band(measurement)), + verdict_cell, + ]) + + if not rows: + return [nodes.paragraph( + text="No failing measurements." if failures_only + else "No measurements were captured." + )] + + return [build_table(headers, rows, [20, 22, 16, 24, 18], + classes=["gl-detail"])] + + +class CISummary(ResultsDirective): + """Per-stage pass counts for each PDK.""" + + def run(self): + store = self.store + if store.empty: + return unavailable("run summary") + + headers = ["Stage"] + list(store.pdks) + rows = [] + for stage in STAGES: + if not any(store.ran(stage, pdk) for pdk in store.pdks): + continue + row: list[nodes.Node] = [text_cell(STAGE_LABELS[stage])] + for pdk in store.pdks: + counts = store.counts(stage, pdk) + if counts is None: + row.append(status_node("missing")) + continue + + total = counts.get("total", 0) + if total == 0: + # The runners write a no-op summary with a note rather than + # failing when nothing is wired up yet. + cell = nodes.paragraph() + cell += nodes.inline("", "\u2014 nothing to run", + classes=["gl-skip"]) + row.append(cell) + continue + + passed = counts.get("pass", 0) + failed = counts.get("fail", 0) + counts.get("error", 0) + css = "gl-fail" if failed else "gl-pass" + symbol = "\u274c" if failed else "\u2705" + cell = nodes.paragraph() + cell += nodes.inline("", f"{symbol} {passed}/{total}", classes=[css]) + row.append(cell) + rows.append(row) + + widths = [30] + [70 // max(1, len(store.pdks))] * len(store.pdks) + return [build_table(headers, rows, widths, classes=["gl-summary"])] + + +class ResultsProvenance(ResultsDirective): + """Where the displayed data came from, and how old it is.""" + + def run(self): + store = self.store + if store.empty: + return [] + + parts: list[str] = [] + commit = os.environ.get("GLAYOUT_RUN_COMMIT") + if commit: + parts.append(f"commit {commit[:7]}") + if store.newest: + stamp = datetime.fromtimestamp(store.newest, tz=timezone.utc) + parts.append(f"results written {stamp:%Y-%m-%d %H:%M UTC}") + parts.append( + "from " + ", ".join(sorted( + f"{STAGE_LABELS[s]} {p}" for (s, p) in store.data + )) + ) + + para = nodes.paragraph(classes=["gl-provenance"]) + run_url = os.environ.get("GLAYOUT_RUN_URL") + text = " \u00b7 ".join(parts) + if run_url: + para += nodes.Text(text + " \u00b7 ") + para += nodes.reference("", "workflow run", refuri=run_url) + else: + para += nodes.Text(text) + return [para] + + +def setup(app): + app.add_config_value("glayout_results_root", "data/sample", "env") + app.add_config_value("glayout_pdks", ["sky130", "gf180"], "env") + app.add_config_value("glayout_results_strict", False, "env") + + app.add_directive("verification-matrix", VerificationMatrix) + app.add_directive("ngspice-detail", NgspiceDetail) + app.add_directive("ci-summary", CISummary) + app.add_directive("results-provenance", ResultsProvenance) + + return { + "version": __version__, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/sphinx/_static/glayout.css b/sphinx/_static/glayout.css new file mode 100644 index 00000000..7ff62b7e --- /dev/null +++ b/sphinx/_static/glayout.css @@ -0,0 +1,50 @@ +/* Status colouring for the generated verification tables. The tokens are + emitted by sphinx/_ext/glayout_results.py; keep the class names in sync. */ + +:root { + --gl-pass: #1a7f37; + --gl-fail: #cf222e; + --gl-warn: #9a6700; + --gl-skip: #6e7781; +} + +body[data-theme="dark"] { + --gl-pass: #3fb950; + --gl-fail: #f85149; + --gl-warn: #d29922; + --gl-skip: #8b949e; +} + +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) { + --gl-pass: #3fb950; + --gl-fail: #f85149; + --gl-warn: #d29922; + --gl-skip: #8b949e; + } +} + +.gl-pass { color: var(--gl-pass); font-weight: 600; white-space: nowrap; } +.gl-fail { color: var(--gl-fail); font-weight: 600; white-space: nowrap; } +.gl-warn { color: var(--gl-warn); font-weight: 600; white-space: nowrap; } +.gl-skip { color: var(--gl-skip); white-space: nowrap; } + +/* The matrix is wide; let it use the full content width and shrink slightly + rather than scrolling horizontally on a laptop screen. */ +table.gl-matrix, +table.gl-detail, +table.gl-summary { + width: 100%; + font-size: 0.88rem; +} + +table.gl-matrix th, +table.gl-summary th { + font-size: 0.82rem; +} + +p.gl-provenance { + font-size: 0.85rem; + color: var(--gl-skip); + margin-top: -0.5rem; +} diff --git a/sphinx/api.rst b/sphinx/api.rst new file mode 100644 index 00000000..09b577d7 --- /dev/null +++ b/sphinx/api.rst @@ -0,0 +1,39 @@ +API reference +============= + +Common entry points +------------------- + +.. list-table:: + :widths: 35 65 + :header-rows: 1 + + * - Task + - API + * - Activate a PDK + - :class:`glayout.pdk.mappedpdk.MappedPDK` + * - Query rules and layers + - :meth:`~glayout.pdk.mappedpdk.MappedPDK.get_grule`, + :meth:`~glayout.pdk.mappedpdk.MappedPDK.get_glayer` + * - Generate primitives + - :mod:`glayout.primitives.fet`, :mod:`glayout.primitives.via_gen`, + :mod:`glayout.primitives.guardring` + * - Generate elementary cells + - :mod:`glayout.cells.elementary` + * - Generate composite blocks + - :mod:`glayout.cells.composite` + * - Natural language front end + - :mod:`glayout.llm` + +Full module reference +--------------------- + +The module index below is discovered from the installed package at build +time, so it tracks the source tree rather than a hand-maintained list. A +subpackage without an ``__init__.py``, or one that raises on import, is +skipped rather than failing the build. + +.. toctree:: + :maxdepth: 2 + + api_modules diff --git a/sphinx/ci.rst b/sphinx/ci.rst new file mode 100644 index 00000000..83c11fef --- /dev/null +++ b/sphinx/ci.rst @@ -0,0 +1,224 @@ +CI flow +======= + +Pull requests and pushes to ``main`` trigger the generate → verify → +simulate pipeline, and a push to ``main`` additionally rebuilds and +deploys this site. + +Workflows +--------- + +The workflows are chained by ``workflow_run`` rather than run as one job. +DRC produces the artifact that LVS and ngspice both consume, so those two +run in parallel off the same input and neither blocks the other. + +.. list-table:: + :widths: 26 26 28 20 + :header-rows: 1 + + * - Workflow + - Trigger + - Produces + - PDK matrix + * - ``drc.yml`` (Cell DRC) + - push, PR + - ``gds/``, ``netlists/``, ``reports/``, ``summary.json`` + - sky130, gf180 + * - ``lvs.yml`` (Automated: Cell LVS) + - after Cell DRC + - ``summary.json``, ``junit.xml`` + - sky130, gf180 + * - ``sim.yml`` (Automated: Cell ngspice) + - after Cell DRC + - ``summary.json``, ``junit.xml``, decks and logs + - sky130 + * - ``docs.yml`` + - after ngspice, push to ``main`` + - this site + - — + +Both LVS and ngspice trigger on DRC finishing, success **or** failure — a +DRC violation does not invalidate the netlist, and running LVS on the +cells that did pass beats a cascade of skips. Cancelled runs are the one +case that is skipped, since their artifacts are partial. + +Everything downstream of DRC consumes the same artifact rather than +rebuilding the cells, which is why a full pipeline costs roughly one +build rather than three. + +.. list-table:: Artifacts each workflow publishes + :widths: 24 30 46 + :header-rows: 1 + + * - Artifact + - Written by + - Contents + * - ``drc-`` + - ``run_cell_drc.py`` + - ``gds/``, ``netlists/`` (consumed by LVS and ngspice), + ``reports/*.lyrdb``, ``summary.json``, ``junit.xml`` + * - ``lvs-`` + - ``run_cell_lvs.py`` + - Netgen or KLayout-deck comparison output, ``summary.json``, + ``junit.xml`` + * - ``sim-`` + - ``run_cell_sim.py`` + - Assembled decks, ngspice logs, ``summary.json``, ``junit.xml`` + +.. note:: + + LVS takes different routes per PDK: sky130 runs magic + netgen through + ``pdk.lvs_netgen``, while gf180 drives the PDK's own KLayout LVS deck, + because magic mis-extracts the gf180 substrate — NMOS bulks merge into + VDD through the n-well. Both write the same ``summary.json`` shape, so + the tables here do not care which ran. + +Permissions the docs workflow needs +----------------------------------- + +Reading artifacts from a *different* workflow run requires ``actions: +read``, the same grant ``lvs.yml`` declares for its DRC download. Without +it the download step fails with "Resource not accessible by integration" +and the tables silently fall back to sample data. + +A ``workflow_run`` event also reports ``github.ref`` as the default +branch no matter which branch actually ran, so the deploy job gates on +``github.event.workflow_run.head_branch`` instead. Without that guard, a +pull-request branch's results would be published to the live site. + +.. note:: + + ``sim.yml`` was deleted in commit ``84119ec`` and restored afterwards. + If simulation is not running on your branch, check that the workflow + file exists. + +Latest run summary +------------------ + +.. results-provenance:: + +.. ci-summary:: + +Artifacts +--------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Artifact + - Contents + * - ``drc-`` + - ``gds/``, ``netlists/`` (reference netlists consumed by LVS and + ngspice), ``reports/*.lyrdb``, ``summary.json`` + * - ``lvs-`` + - Netgen comparison logs, ``summary.json``, ``junit.xml`` + * - ``sim-`` + - ``summary.json``, ``junit.xml``, per-cell assembled decks and logs + +Each runner's ``summary.json`` is the source for the tables on this site +— see :doc:`reporting`. + +Deployment +---------- + +The site is built by Sphinx into ``_site`` and published to GitHub Pages. +The live dashboard is copied in from ``docs/live/`` as a sibling path, so +the Sphinx site serves from the root and the dashboard from ``/live/``: + +.. code-block:: yaml + + - name: Stage _site/ (live dashboard) + run: | + rm -rf _site && mkdir _site + cp -r docs/. _site/ + + - name: Build Sphinx site (root) + run: | + sphinx-build -b html --keep-going sphinx _site + touch _site/.nojekyll + +The ``.nojekyll`` marker matters: without it GitHub Pages runs Jekyll, +which strips directories beginning with an underscore and breaks +``_static``. + +One-time repository setup +~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Settings → Pages → Build and deployment → Source: GitHub Actions.** + Not "Deploy from a branch" — the workflow uses the Pages deployment + API and needs no ``gh-pages`` branch. +2. Confirm the workflow has ``pages: write`` and ``id-token: write`` + permissions. + +The site then appears at ``https://reallmasic.github.io/gLayout/``. + +Building locally +~~~~~~~~~~~~~~~~ + +``run_webpage.sh`` at the repository root does what the ``build`` job +does, then serves the result: + +.. code-block:: console + + ./run_webpage.sh # build + serve on :8000 + ./run_webpage.sh 8080 # different port + ./run_webpage.sh --no-build # serve an existing _site/ + ./run_webpage.sh --live # dashboard only, skip Sphinx + ./run_webpage.sh --strict # fail if results.json is absent + +It prefers ``uv run sphinx-build`` when ``uv`` is installed and falls +back to whatever ``sphinx-build`` is on ``PATH``. In ``--live`` mode it +copies the results file into the served tree and rewrites +``live/config.js`` to fetch it relatively, so the dashboard works without +network access. + +Docs dependencies live in the ``docs`` dependency group: + +.. code-block:: console + + uv sync --group docs + # or, with pip: + pip install sphinx furo myst-parser + +Reproducing the pipeline locally +-------------------------------- + +Every stage is an ordinary shell invocation, so anything CI does can be +run on a laptop with the EDA tools installed. This is usually faster than +pushing a commit to find out whether a fix worked. + +``conf.py`` prefers ``sim_results/results.json`` at the repository root +when it exists, so a local run is picked up with no extra flags: + +.. code-block:: console + + python tests/sim/run_cell_sim.py --pdk sky130 --all + sphinx-build -b html sphinx _site + +Version skew is the most common reason a stage passes locally and fails +in CI. Check what the workflow pins: + +.. code-block:: console + + grep -A2 'ngspice\|magic\|netgen\|klayout' .github/workflows/sim.yml + +``ngspice`` in particular changed ``.measure`` behaviour across versions +— a deck that reports a measurement on one version may silently omit it +on another, which the parser records as a failure rather than a pass. + +Keeping generated output out of git +----------------------------------- + +.. code-block:: text + + lvs_results/ + sim_results/ + _site/ + *.raw + *.lyrdb + +``sim_results/results.json`` is the exception if the live dashboard is in +use: that one file needs to be committed for the page to have something +to fetch. Ignore the directory and force-add the single file with +``git add -f sim_results/results.json``. diff --git a/sphinx/conf.py b/sphinx/conf.py new file mode 100644 index 00000000..f72efafb --- /dev/null +++ b/sphinx/conf.py @@ -0,0 +1,208 @@ +"""Sphinx configuration for the Glayout documentation.""" +from __future__ import annotations + +import os +import sys +from importlib import metadata +from importlib.util import find_spec +import pathlib +from pathlib import Path + +HERE = Path(__file__).parent.resolve() +REPO_ROOT = HERE.parent + +# Allow autodoc to import the package without a prior `pip install .` +# (CI installs the package, which makes this a no-op there, but keeps +# `sphinx-build` working from a fresh checkout). +sys.path.insert(0, os.path.abspath("../src")) + +# Local extension providing the verification-results directives. +sys.path.insert(0, str(HERE / "_ext")) + +project = "glayout" +author = "ReaLLMASIC" +copyright = "2026, ReaLLMASIC" + +try: + release = metadata.version("glayout") +except metadata.PackageNotFoundError: + release = "0.0.0" +version = ".".join(release.split(".")[:2]) + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.intersphinx", + "myst_parser", + "glayout_results", +] + +# The API reference is generated from docstrings and needs the package to be +# importable. A bare checkout without `pip install -e .` should still build the +# narrative docs, so autosummary is switched off in that case and api.rst +# renders an install hint instead of aborting the build. +GLAYOUT_IMPORTABLE = find_spec("glayout") is not None + + +def _discover_modules(package: str = "glayout") -> list[str]: + """Top-level subpackages of *package*, discovered rather than hard-coded. + + autosummary raises a fatal ExtensionError on the first name it cannot + import, so listing modules by hand makes the build hostage to the package + layout — a directory without ``__init__.py`` takes the whole build down. + + Only depth-1 names are returned: the ``:recursive:`` flag on the autosummary + directive walks everything below them. Listing deeper names as well puts the + same stub in two toctrees and produces one warning per module. Where a + depth-1 package cannot be resolved, its immediate children are listed + instead so its contents are not lost. + """ + if not GLAYOUT_IMPORTABLE: + return [] + + import pkgutil + + spec = find_spec(package) + if spec is None or not spec.submodule_search_locations: + return [] + + search = list(spec.submodule_search_locations) + + def resolves(name: str) -> bool: + try: + return find_spec(name) is not None + except (ImportError, AttributeError, ValueError): + return False + + found: list[str] = [] + try: + for info in pkgutil.iter_modules(search, prefix=f"{package}."): + leaf = info.name.rsplit(".", 1)[-1] + if leaf.startswith("_") or leaf in ("tests", "test"): + continue + if resolves(info.name): + found.append(info.name) + elif info.ispkg: + # Unimportable parent (usually a missing __init__.py): keep the + # children that do resolve rather than dropping the subtree. + child_paths = [str(pathlib.Path(p) / leaf) for p in search] + for child in pkgutil.iter_modules(child_paths, + prefix=f"{info.name}."): + if resolves(child.name): + found.append(child.name) + except Exception: # discovery is best-effort; never fail the build over it + return [] + return sorted(found) + + +GLAYOUT_MODULES = _discover_modules() + +# Written on every build as a real page (api.rst lists it in a toctree) rather +# than an included fragment: autosummary scans documents, so a fragment that is +# excluded from the build never gets its stub pages generated. +# +# This file is build output, not source — keep it gitignored. +_api_page = HERE / "api_modules.rst" +_header = "Module index\n============\n\n" +if GLAYOUT_MODULES: + _api_page.write_text( + _header + + f"Discovered from the installed package ({len(GLAYOUT_MODULES)} modules).\n\n" + ".. autosummary::\n" + " :toctree: _autosummary\n" + " :recursive:\n\n" + + "".join(f" {name}\n" for name in GLAYOUT_MODULES), + encoding="utf-8", + ) +else: + _api_page.write_text( + _header + + "The module reference is generated from docstrings and needs\n" + "``glayout`` to be importable. Install the package and rebuild to\n" + "populate this section:\n\n" + ".. code-block:: console\n\n" + " pip install -e .\n", + encoding="utf-8", + ) + +autosummary_generate = bool(GLAYOUT_MODULES) +autodoc_default_options = { + "members": True, + "undoc-members": False, + "show-inheritance": True, + "ignore-module-all": True, +} +autodoc_typehints = "description" +autodoc_member_order = "bysource" + +# The layout generators pull in EDA-adjacent packages that are not installed in +# the docs environment. Mock them so autodoc can still read signatures. +autodoc_mock_imports = [ + "gdsfactory", + "gdstk", + "klayout", + "sky130", + "gf180", + "torch", +] + +napoleon_google_docstring = True +napoleon_numpy_docstring = True + +if os.environ.get("GLAYOUT_ENABLE_INTERSPHINX"): + intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "gdsfactory": ("https://gdsfactory.github.io/gdsfactory/", None), + } +else: + intersphinx_mapping = {} + +myst_enable_extensions = ["colon_fence", "deflist"] + +templates_path = ["_templates"] +exclude_patterns = [ + "_build", "data", "Thumbs.db", ".DS_Store", "README.md", +] +source_suffix = {".rst": "restructuredtext", ".md": "markdown"} + +# -- Verification results ---------------------------------------------------- +# The result tables are generated at build time from the summary.json files the +# runners already write: +# +# /drc_results//summary.json +# /lvs_results//summary.json +# /sim_results//summary.json +# +# A local run or a CI artifact download puts those at the repo root; a bare +# checkout falls back to the committed sample so the docs always build. + +glayout_pdks = ["sky130", "gf180"] + +_local_results = REPO_ROOT / "sim_results" +glayout_results_root = os.environ.get("GLAYOUT_RESULTS_ROOT") or str( + REPO_ROOT if _local_results.exists() else HERE / "data" / "sample" +) + +# Set GLAYOUT_RESULTS_STRICT=1 in CI so missing or malformed runner output fails +# the build instead of quietly publishing sample data. +glayout_results_strict = os.environ.get("GLAYOUT_RESULTS_STRICT") == "1" + +html_theme = "furo" +html_static_path = ["_static"] +html_css_files = ["glayout.css"] +html_title = f"glayout {release}" + +html_theme_options = { + "source_repository": "https://github.com/ReaLLMASIC/gLayout/", + "source_branch": "main", + "source_directory": "sphinx/", +} + + +def setup(app): + """Expose package availability to the documents as a Sphinx tag.""" + if GLAYOUT_IMPORTABLE: + app.tags.add("has_glayout") + return {"parallel_read_safe": True} diff --git a/sphinx/contributing.rst b/sphinx/contributing.rst new file mode 100644 index 00000000..2de8ec6c --- /dev/null +++ b/sphinx/contributing.rst @@ -0,0 +1,97 @@ +Contributing +============ + +The full guide lives at `docs/contributor_guide.md +`_. +This page covers what the verification flow expects of a change. + +Before opening a pull request +----------------------------- + +.. admonition:: Checklist + :class: tip + + * New generators query the PDK for rules rather than hard-coding + dimensions + * Ports are named and documented + * A testbench exists in ``tests/sim/testbenches/`` for any new cell, + containing stimulus and ``.measure`` cards only — no ``.lib``, no + ``.include`` of the DUT + * Bands added to ``tests/sim/testbenches/checks.json`` where the cell + has a real spec (without them it runs as a smoke test) + * DRC and LVS pass locally on sky130 + * Docs build clean: ``./run_webpage.sh`` + * Generated output (``lvs_results/``, ``sim_results/``, ``*.raw``) is + not committed + +Working with a fork +------------------- + +Most contributors do not have push access to ``ReaLLMASIC/gLayout``, so +the flow is fork → branch → pull request: + +.. code-block:: console + + git clone https://github.com//gLayout.git + cd gLayout + git remote add upstream https://github.com/ReaLLMASIC/gLayout.git + +Keep the fork current before branching, or the pull request will carry +unrelated commits: + +.. code-block:: console + + git fetch upstream + git checkout main + git merge --ff-only upstream/main + git push origin main + git checkout -b my-feature + +.. tip:: + + Base new branches on ``upstream/main``, not on your fork's ``main``. A + stale fork is the usual cause of a pull request showing dozens of + unexpected commits. + +When a merge seems to have vanished +----------------------------------- + +A file that disappeared after a successful merge was usually moved or +removed by a later commit rather than lost. Trace it before redoing the +work: + +.. code-block:: console + + git log --full-history --oneline upstream/main -- '**/' + +``--full-history`` matters — without it git prunes merge history and can +hide the commit that removed the file. ``git branch -r --contains `` +confirms whether a commit is reachable from the branch you expect. + +To restore a tree from before a deletion: + +.. code-block:: console + + git checkout -b restore-x upstream/main + git checkout -- path/to/dir/ + git commit -m "Restore path/to/dir deleted in " + git push -u origin restore-x + +.. note:: + + Pushing changes under ``.github/workflows/`` over HTTPS requires a + token with the ``workflow`` scope in addition to ``repo``. A ``403`` + naming a different account than the repository owner means a stale + credential is cached, not a permissions problem on the repository. + +Documentation changes +--------------------- + +The docs live in ``sphinx/``. Result tables are generated from +``results.json`` and must not be edited by hand — see :doc:`reporting`. + +.. code-block:: console + + uv sync --group docs # or: pip install sphinx furo myst-parser + ./run_webpage.sh # build and serve on :8000 + ./run_webpage.sh --live # dashboard only, much faster diff --git a/sphinx/data/sample/drc_results/gf180/summary.json b/sphinx/data/sample/drc_results/gf180/summary.json new file mode 100644 index 00000000..49359909 --- /dev/null +++ b/sphinx/data/sample/drc_results/gf180/summary.json @@ -0,0 +1,64 @@ +{ + "pdk": "gf180", + "total": 9, + "pass": 9, + "fail": 0, + "error": 0, + "skip": 0, + "results": [ + { + "cell": "current_mirror_nfet", + "pdk": "gf180", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "current_mirror_pfet", + "pdk": "gf180", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "diff_pair", + "pdk": "gf180", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "diff_pair_ibias", + "pdk": "gf180", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "flipped_voltage_follower", + "pdk": "gf180", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "low_voltage_cmirror", + "pdk": "gf180", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "transmission_gate", + "pdk": "gf180", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "opamp", + "pdk": "gf180", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "diffpair_cmirror_bias", + "pdk": "gf180", + "status": "pass", + "message": "drc clean" + } + ] +} \ No newline at end of file diff --git a/sphinx/data/sample/drc_results/sky130/summary.json b/sphinx/data/sample/drc_results/sky130/summary.json new file mode 100644 index 00000000..9db81374 --- /dev/null +++ b/sphinx/data/sample/drc_results/sky130/summary.json @@ -0,0 +1,64 @@ +{ + "pdk": "sky130", + "total": 9, + "pass": 9, + "fail": 0, + "error": 0, + "skip": 0, + "results": [ + { + "cell": "current_mirror_nfet", + "pdk": "sky130", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "current_mirror_pfet", + "pdk": "sky130", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "diff_pair", + "pdk": "sky130", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "diff_pair_ibias", + "pdk": "sky130", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "flipped_voltage_follower", + "pdk": "sky130", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "low_voltage_cmirror", + "pdk": "sky130", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "transmission_gate", + "pdk": "sky130", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "opamp", + "pdk": "sky130", + "status": "pass", + "message": "drc clean" + }, + { + "cell": "diffpair_cmirror_bias", + "pdk": "sky130", + "status": "pass", + "message": "drc clean" + } + ] +} \ No newline at end of file diff --git a/sphinx/data/sample/lvs_results/gf180/summary.json b/sphinx/data/sample/lvs_results/gf180/summary.json new file mode 100644 index 00000000..b8a4cef6 --- /dev/null +++ b/sphinx/data/sample/lvs_results/gf180/summary.json @@ -0,0 +1,64 @@ +{ + "pdk": "gf180", + "total": 9, + "pass": 8, + "fail": 1, + "error": 0, + "skip": 0, + "results": [ + { + "cell": "current_mirror_nfet", + "pdk": "gf180", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "current_mirror_pfet", + "pdk": "gf180", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "diff_pair", + "pdk": "gf180", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "diff_pair_ibias", + "pdk": "gf180", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "flipped_voltage_follower", + "pdk": "gf180", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "low_voltage_cmirror", + "pdk": "gf180", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "transmission_gate", + "pdk": "gf180", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "opamp", + "pdk": "gf180", + "status": "fail", + "message": "net mismatch: 1 net" + }, + { + "cell": "diffpair_cmirror_bias", + "pdk": "gf180", + "status": "pass", + "message": "lvs match" + } + ] +} \ No newline at end of file diff --git a/sphinx/data/sample/lvs_results/sky130/summary.json b/sphinx/data/sample/lvs_results/sky130/summary.json new file mode 100644 index 00000000..54aa4bd5 --- /dev/null +++ b/sphinx/data/sample/lvs_results/sky130/summary.json @@ -0,0 +1,64 @@ +{ + "pdk": "sky130", + "total": 9, + "pass": 9, + "fail": 0, + "error": 0, + "skip": 0, + "results": [ + { + "cell": "current_mirror_nfet", + "pdk": "sky130", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "current_mirror_pfet", + "pdk": "sky130", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "diff_pair", + "pdk": "sky130", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "diff_pair_ibias", + "pdk": "sky130", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "flipped_voltage_follower", + "pdk": "sky130", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "low_voltage_cmirror", + "pdk": "sky130", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "transmission_gate", + "pdk": "sky130", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "opamp", + "pdk": "sky130", + "status": "pass", + "message": "lvs match" + }, + { + "cell": "diffpair_cmirror_bias", + "pdk": "sky130", + "status": "pass", + "message": "lvs match" + } + ] +} \ No newline at end of file diff --git a/sphinx/data/sample/sim_results/sky130/summary.json b/sphinx/data/sample/sim_results/sky130/summary.json new file mode 100644 index 00000000..4b617fea --- /dev/null +++ b/sphinx/data/sample/sim_results/sky130/summary.json @@ -0,0 +1,310 @@ +{ + "pdk": "sky130", + "total": 9, + "pass": 9, + "fail": 0, + "error": 0, + "skip": 0, + "results": [ + { + "cell": "current_mirror_nfet", + "pdk": "sky130", + "status": "pass", + "message": "sim passed", + "returncode": 0, + "deck": "reports/sim/current_mirror_nfet/current_mirror_nfet.deck.spice", + "log": "reports/sim/current_mirror_nfet/current_mirror_nfet.log", + "summary": { + "is_pass": true, + "conclusion": "sim passed", + "measures": { + "iout": 3.962e-06, + "vout_min": 0.34 + }, + "failed_measures": [], + "check_violations": [], + "rows": [ + { + "name": "iout", + "value": 3.962e-06, + "min": null, + "max": 4.2e-06, + "verdict": "PASS" + }, + { + "name": "vout_min", + "value": 0.34, + "min": null, + "max": 0.4, + "verdict": "PASS" + } + ], + "raw_tail": "" + } + }, + { + "cell": "current_mirror_pfet", + "pdk": "sky130", + "status": "pass", + "message": "sim passed", + "returncode": 0, + "deck": "reports/sim/current_mirror_pfet/current_mirror_pfet.deck.spice", + "log": "reports/sim/current_mirror_pfet/current_mirror_pfet.log", + "summary": { + "is_pass": true, + "conclusion": "sim passed", + "measures": { + "iout": 3.948e-06 + }, + "failed_measures": [], + "check_violations": [], + "rows": [ + { + "name": "iout", + "value": 3.948e-06, + "min": null, + "max": 4.2e-06, + "verdict": "PASS" + } + ], + "raw_tail": "" + } + }, + { + "cell": "diff_pair", + "pdk": "sky130", + "status": "pass", + "message": "sim passed", + "returncode": 0, + "deck": "reports/sim/diff_pair/diff_pair.deck.spice", + "log": "reports/sim/diff_pair/diff_pair.log", + "summary": { + "is_pass": true, + "conclusion": "sim passed", + "measures": { + "gain_db": 25.8, + "tphl": 1.19e-09 + }, + "failed_measures": [], + "check_violations": [], + "rows": [ + { + "name": "gain_db", + "value": 25.8, + "min": 24.0, + "max": 28.0, + "verdict": "PASS" + }, + { + "name": "tphl", + "value": 1.19e-09, + "min": null, + "max": 2e-09, + "verdict": "PASS" + } + ], + "raw_tail": "" + } + }, + { + "cell": "diff_pair_ibias", + "pdk": "sky130", + "status": "pass", + "message": "sim passed", + "returncode": 0, + "deck": "reports/sim/diff_pair_ibias/diff_pair_ibias.deck.spice", + "log": "reports/sim/diff_pair_ibias/diff_pair_ibias.log", + "summary": { + "is_pass": true, + "conclusion": "sim passed", + "measures": { + "ibias": 1.96e-05 + }, + "failed_measures": [], + "check_violations": [], + "rows": [ + { + "name": "ibias", + "value": 1.96e-05, + "min": 1.8e-05, + "max": 2.2e-05, + "verdict": "PASS" + } + ], + "raw_tail": "" + } + }, + { + "cell": "flipped_voltage_follower", + "pdk": "sky130", + "status": "pass", + "message": "sim passed", + "returncode": 0, + "deck": "reports/sim/flipped_voltage_follower/flipped_voltage_follower.deck.spice", + "log": "reports/sim/flipped_voltage_follower/flipped_voltage_follower.log", + "summary": { + "is_pass": true, + "conclusion": "sim passed", + "measures": { + "vout": 0.892 + }, + "failed_measures": [], + "check_violations": [], + "rows": [ + { + "name": "vout", + "value": 0.892, + "min": 0.85, + "max": 0.95, + "verdict": "PASS" + } + ], + "raw_tail": "" + } + }, + { + "cell": "low_voltage_cmirror", + "pdk": "sky130", + "status": "pass", + "message": "sim passed", + "returncode": 0, + "deck": "reports/sim/low_voltage_cmirror/low_voltage_cmirror.deck.spice", + "log": "reports/sim/low_voltage_cmirror/low_voltage_cmirror.log", + "summary": { + "is_pass": true, + "conclusion": "sim passed", + "measures": { + "iout": 9.81e-06, + "v_headroom": 0.31 + }, + "failed_measures": [], + "check_violations": [], + "rows": [ + { + "name": "iout", + "value": 9.81e-06, + "min": 9.5e-06, + "max": 1.05e-05, + "verdict": "PASS" + }, + { + "name": "v_headroom", + "value": 0.31, + "min": null, + "max": 0.35, + "verdict": "PASS" + } + ], + "raw_tail": "" + } + }, + { + "cell": "transmission_gate", + "pdk": "sky130", + "status": "pass", + "message": "sim passed", + "returncode": 0, + "deck": "reports/sim/transmission_gate/transmission_gate.deck.spice", + "log": "reports/sim/transmission_gate/transmission_gate.log", + "summary": { + "is_pass": true, + "conclusion": "sim passed", + "measures": { + "tphl": 1.58e-10, + "tplh": 1.62e-10 + }, + "failed_measures": [], + "check_violations": [], + "rows": [ + { + "name": "tphl", + "value": 1.58e-10, + "min": null, + "max": 2e-10, + "verdict": "PASS" + }, + { + "name": "tplh", + "value": 1.62e-10, + "min": null, + "max": 2e-10, + "verdict": "PASS" + } + ], + "raw_tail": "" + } + }, + { + "cell": "opamp", + "pdk": "sky130", + "status": "pass", + "message": "sim passed", + "returncode": 0, + "deck": "reports/sim/opamp/opamp.deck.spice", + "log": "reports/sim/opamp/opamp.log", + "summary": { + "is_pass": true, + "conclusion": "sim passed", + "measures": { + "gain_db": 60.2, + "ugb": 11100000.0, + "phase_margin": 61.8 + }, + "failed_measures": [], + "check_violations": [], + "rows": [ + { + "name": "gain_db", + "value": 60.2, + "min": 55.0, + "max": null, + "verdict": "PASS" + }, + { + "name": "ugb", + "value": 11100000.0, + "min": 8000000.0, + "max": null, + "verdict": "PASS" + }, + { + "name": "phase_margin", + "value": 61.8, + "min": 55.0, + "max": null, + "verdict": "PASS" + } + ], + "raw_tail": "" + } + }, + { + "cell": "diffpair_cmirror_bias", + "pdk": "sky130", + "status": "pass", + "message": "sim passed", + "returncode": 0, + "deck": "reports/sim/diffpair_cmirror_bias/diffpair_cmirror_bias.deck.spice", + "log": "reports/sim/diffpair_cmirror_bias/diffpair_cmirror_bias.log", + "summary": { + "is_pass": true, + "conclusion": "sim passed", + "measures": { + "gain_db": 33.1 + }, + "failed_measures": [], + "check_violations": [], + "rows": [ + { + "name": "gain_db", + "value": 33.1, + "min": 30.0, + "max": null, + "verdict": "PASS" + } + ], + "raw_tail": "" + } + } + ] +} \ No newline at end of file diff --git a/sphinx/generators.rst b/sphinx/generators.rst new file mode 100644 index 00000000..3bc467f1 --- /dev/null +++ b/sphinx/generators.rst @@ -0,0 +1,96 @@ +Generators +========== + +The library is layered: primitives wrap PDK geometry, elementary cells +compose primitives into recognisable analog structures, and composite +cells build blocks from those. + +Primitives +---------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Generator + - Description + * - ``via_stack`` + - Via and enclosure geometry between any two metal layers + * - ``nmos`` / ``pmos`` + - Multi-finger transistors with optional dummies and taps + * - ``guard_ring`` + - Well/substrate isolation ring around an arbitrary bounding box + * - ``tapring`` + - Tap ring for latch-up prevention + +Elementary cells +---------------- + +Each of these has a testbench in the regression matrix — see +:doc:`results`. + +.. list-table:: + :widths: 32 68 + :header-rows: 1 + + * - Generator + - Description + * - ``current_mirror_nfet`` / ``current_mirror_pfet`` + - Ratioed mirror with matched-device placement + * - ``diff_pair`` + - Differential pair with common source and interdigitated fingers + * - ``diff_pair_ibias`` + - Differential pair with integrated bias current source + * - ``flipped_voltage_follower`` + - FVF cell for low-impedance buffering + * - ``low_voltage_cmirror`` + - Cascoded mirror optimised for headroom + * - ``transmission_gate`` + - Complementary pass gate + +Composite cells +--------------- + +.. list-table:: + :widths: 32 68 + :header-rows: 1 + + * - Generator + - Description + * - ``opamp`` + - Two-stage operational amplifier with compensation + * - ``diffpair_cmirror_bias`` + - Differential pair with current-mirror bias network + +Writing a generator +------------------- + +Generators take a ``MappedPDK`` first and return a component: + +.. code-block:: python + + from glayout.pdk.mappedpdk import MappedPDK + from gdsfactory import Component + + def my_cell(pdk: MappedPDK, width: float = 1.0, + length: float = 0.15) -> Component: + pdk.activate() + cell = Component() + # place subcells, add ports, route + return cell + +Three conventions make a generator usable by the rest of the framework: + +Query the PDK, never hard-code + Use ``pdk.get_grule()`` and ``pdk.get_glayer()`` for spacing and + layers. A literal ``0.15`` in a generator means it is not + PDK-agnostic. + +Expose named ports + Composite cells route to ports, not coordinates. + +Ship a testbench + A generator with no entry in ``tests/sim/testbenches/`` is not covered + by regression, so nothing catches a behavioural break. + +See :doc:`contributing` for review expectations. diff --git a/sphinx/getting_started.rst b/sphinx/getting_started.rst new file mode 100644 index 00000000..0ec9f538 --- /dev/null +++ b/sphinx/getting_started.rst @@ -0,0 +1,147 @@ +Getting started +=============== + +This page walks through installing Glayout, generating a component, and +running the verification flow on it. The examples are small and can be +pasted into a Python REPL in order. + +Install +------- + +Glayout needs Python ≥ 3.10. With ``pip``: + +.. code-block:: console + + pip install glayout + +From a checkout, for development: + +.. code-block:: console + + git clone https://github.com/ReaLLMASIC/gLayout.git + cd gLayout + pip install -e ".[dev]" + +Optional extras: ``[ml]`` for the reinforcement-learning tooling, +``[llm]`` for the natural-language front end. Documentation dependencies +are in the ``docs`` dependency group (``uv sync --group docs``). + +External tools +~~~~~~~~~~~~~~ + +The layout generators are pure Python and need nothing else. The +verification flow shells out to EDA tools, so install these only if you +intend to run DRC, LVS, or simulation: + +.. list-table:: + :widths: 22 28 50 + :header-rows: 1 + + * - Requirement + - Needed for + - Notes + * - ``klayout`` + - DRC + - invoked with the PDK's own rule deck + * - ``magic`` + - LVS + - netlist extraction + * - ``netgen`` + - LVS + - netlist comparison + * - ``ngspice`` + - simulation + - version 39 or newer recommended + * - ``PDK_ROOT`` + - all three + - points at an installed sky130A / gf180mcuD PDK + +Generate a component +-------------------- + +Every generator takes a PDK object as its first argument. The same call +produces a layout in whichever technology is passed in — that is the +point of the framework: + +.. code-block:: python + + from glayout import sky130, gf180, nmos, pmos, via_stack + + # A via stack: met2 is the bottom layer, met3 the top + via = via_stack(sky130, "met2", "met3", centered=True) + + # A two-finger NMOS transistor + transistor = nmos(sky130, width=1.0, length=0.15, fingers=2) + + # The same generator, different technology + transistor_gf = nmos(gf180, width=2.0, length=0.28, fingers=2) + + via.write_gds("via.gds") + transistor.write_gds("transistor.gds") + +Generators return :class:`gdsfactory.Component` objects, so the usual +display paths work: + +.. code-block:: python + + transistor.show() # opens in KLayout + transistor.plot() # inline matplotlib preview + +Inspect ports +------------- + +.. code-block:: python + + print(transistor.ports.keys()) + +Ports are what let composite cells route to primitives without +hard-coded coordinates. A generator with undocumented ports is difficult +to build on — see :doc:`generators` for the conventions. + +Build a composite cell +---------------------- + +.. code-block:: python + + from glayout import sky130, diff_pair + + dp = diff_pair(sky130, width=3.0, length=0.15, fingers=4) + dp.write_gds("diff_pair.gds") + +Verify what you built +--------------------- + +DRC runs first and emits the reference netlists that LVS and ngspice both +consume: + +.. code-block:: console + + python tests/drc/run_cell_drc.py --pdk sky130 --out-dir drc_results/sky130 + + python tests/sim/run_cell_sim.py \\ + --pdk sky130 \\ + --inputs-dir drc_results/sky130 \\ + --out-dir sim_results/sky130 \\ + --cells diff_pair + +Each runner writes a ``summary.json``. Rebuild the docs afterwards and the +tables on :doc:`results` pick up your run automatically — ``conf.py`` +prefers local runner output over the committed sample. See +:doc:`verification` for what each stage checks. + +Natural language interface +-------------------------- + +With the ``[llm]`` extra installed: + +.. code-block:: python + + from glayout.llm import generate + + component = generate( + "a 4-finger nmos current mirror in sky130 with a 1:4 ratio" + ) + +The front end maps descriptions onto the generators in +:doc:`generators`. It does not write new layout code, so its output is +subject to the same DRC guarantees as a hand-written call. diff --git a/sphinx/index.rst b/sphinx/index.rst new file mode 100644 index 00000000..b75b0f7c --- /dev/null +++ b/sphinx/index.rst @@ -0,0 +1,111 @@ +GLAYOUT +======= + +**PDK-agnostic layout automation for analog circuit design.** Glayout +generates DRC-clean layouts for any technology that implements the +framework, and every generator in the library is verified end to end: +physical rules with KLayout, netlist equivalence with Netgen, and +electrical behaviour with ngspice. + +.. admonition:: Live verification status + :class: tip + + Current DRC / LVS / ngspice results for every cell, read from the + runners' own output rather than the last docs deploy. + + 👉 `Open the live status dashboard `_ + + The tables on :doc:`results` are generated when these docs are built, + so they lag the dashboard whenever a run has completed since the last + deploy. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + getting_started + generators + verification + results + ci + reporting + contributing + api + +Current status +-------------- + +.. results-provenance:: + +.. verification-matrix:: + +Per-measurement detail is on :doc:`results`. + +Supported PDKs +-------------- + +.. list-table:: + :widths: 30 14 14 14 14 14 + :header-rows: 1 + + * - PDK + - Node + - Layout + - DRC + - LVS + - ngspice + * - `SKY130A `_ + - 130 nm + - ✅ + - ✅ + - ✅ + - ✅ + * - `GF180MCU-D `_ + - 180 nm + - ✅ + - ✅ + - ✅ + - — + +ngspice regression currently runs on sky130 only. The simulation is a +pre-layout functional check against the reference netlist the DRC runner +emits — see :doc:`verification`. + +New here? Start with :doc:`getting_started` for an installable +walk-through, then :doc:`verification` for how the checks work. + +Citation +-------- + +If you use Glayout in your research, please cite: + +.. code-block:: bibtex + + @article{hammoud2024human, + title={Human Language to Analog Layout Using Glayout Layout Automation + Framework}, + author={Hammoud, A. and Goyal, C. and Pathen, S. and Dai, A. and Li, A. + and Kielian, G. and Saligane, M.}, + journal={Accepted at MLCAD}, + year={2024} + } + + @article{hammoud2024reinforcement, + title={Reinforcement Learning-Enhanced Cloud-Based Open Source Analog + Circuit Generator for Standard and Cryogenic Temperatures in + 130-nm and 180-nm OpenPDKs}, + author={Hammoud, A. and Li, A. and Tripathi, A. and Tian, W. and + Khandeparkar, H. and Wans, R. and Kielian, G. and Murmann, B. + and Sylvester, D. and Saligane, M.}, + journal={Accepted at ICCAD}, + year={2024} + } + +Licensed under MIT. Questions: mehdi_saligane@brown.edu + +Indices +------- + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/sphinx/reporting.rst b/sphinx/reporting.rst new file mode 100644 index 00000000..dae18d97 --- /dev/null +++ b/sphinx/reporting.rst @@ -0,0 +1,164 @@ +Reporting +========= + +No result table in this documentation is written by hand, and none of them +require a change to the runners. Everything reads the ``summary.json`` that +each runner already writes next to its ``junit.xml``. + +.. code-block:: text + + drc_results//summary.json <- tests/drc/run_cell_drc.py + lvs_results//summary.json <- tests/lvs/run_cell_lvs.py + sim_results//summary.json <- tests/sim/run_cell_sim.py + +Three consumers, one set of files: + +* the Sphinx directives on :doc:`results` and :doc:`ci`, at build time; +* the `live dashboard `_, fetched in the browser on page load; +* ``tools/render_results.py``, which fills the marker blocks in the + repository ``README.md``. + +If a table disagrees with a run, the fault is in the summary file, not in +three separate places. + +Schema +------ + +.. code-block:: json + + { + "pdk": "sky130", + "total": 9, "pass": 8, "fail": 1, "error": 0, "skip": 0, + "results": [ + { + "cell": "diff_pair", + "status": "pass", + "message": "sim passed", + "summary": { + "conclusion": "sim passed", + "measures": {"tphl": 1.19e-9}, + "rows": [ + {"name": "tphl", "value": 1.19e-9, + "min": null, "max": 2e-9, "verdict": "PASS"} + ] + } + } + ] + } + +``status`` + One of ``pass``, ``fail``, ``error`` or ``skip``. ``error`` means the + runner itself failed — a timeout or an exception — as distinct from a + cell that ran and did not meet its bands. + +``summary.rows`` + Present for ngspice only. One entry per measurement, carrying the + value, the band it was checked against, and a verdict of ``PASS``, + ``FAIL``, ``MISSING`` or ``n/a``. Built unconditionally, so the table + renders whether the cell passed or failed and the displayed rows can + never disagree with the verdict. + +A run with nothing to do writes ``{"pdk": ..., "total": 0, "note": ...}`` +with no ``results`` key — for instance when netlists exist but no +testbenches have been authored yet. That renders as "nothing to run" +rather than an error, which is what keeps adding the workflow from +reddening the build before the testbenches land. + +Directives +---------- + +Provided by ``sphinx/_ext/glayout_results.py``: + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Directive + - Renders + * - ``verification-matrix`` + - One row per cell, one column per stage that ran, per PDK + * - ``ngspice-detail`` + - One row per measurement. Options: ``:pdk:``, ``:failures-only:`` + * - ``ci-summary`` + - Per-stage pass counts + * - ``results-provenance`` + - Which summaries were loaded and when they were written + +Usage is a bare directive — no data is passed in the document: + +.. code-block:: rst + + .. verification-matrix:: + + .. ngspice-detail:: + :pdk: sky130 + :failures-only: + +Configuration +~~~~~~~~~~~~~ + +.. list-table:: + :widths: 32 68 + :header-rows: 1 + + * - ``conf.py`` value + - Meaning + * - ``glayout_results_root`` + - Directory containing ``_results//summary.json``. + Defaults to the repository root when a local run exists, else the + committed sample. Override with ``GLAYOUT_RESULTS_ROOT``. + * - ``glayout_pdks`` + - Which PDKs to look for and show as columns. + * - ``glayout_results_strict`` + - When true, missing or malformed summaries fail the build instead of + rendering a placeholder. Set ``GLAYOUT_RESULTS_STRICT=1`` in CI. + +The fallback matters: a contributor with no PDK installed can still build +the docs and gets the sample data with a warning, rather than a broken +build. + +Number formatting +~~~~~~~~~~~~~~~~~ + +The directives reimplement ``run_cell_sim.py``'s ``_fmt_eng`` exactly, so +a value shown here reads identically to the same value in the console +log, the JUnit report and the Actions step summary. If you change the +formatter in the runner, change it in +``sphinx/_ext/glayout_results.py`` and ``tools/render_results.py`` too — +they are duplicated deliberately, so the docs build has no import +dependency on the test tree. + +Updating the README +------------------- + +The README is rendered by GitHub and cannot run Sphinx directives, so it +uses comment markers instead: + +.. code-block:: text + + + ... + + +``tools/render_results.py`` rewrites only the text between a matching +pair, leaving surrounding prose untouched: + +.. code-block:: console + + python tools/render_results.py --results-root . --target README.md + +Run it with ``--check`` to verify the README is current without modifying +it; it exits non-zero when stale, which makes a usable PR gate. + +Where CI gets the data +---------------------- + +The docs workflow triggers on the ngspice workflow completing, downloads +the ``sim-`` artifacts from the triggering run and the ``drc-`` +and ``lvs-`` artifacts from the branch, then reshapes them into the +layout above. Each download is best-effort: a stage whose artifact is +missing simply loses its column rather than failing the build. + +The summaries are also copied into the deployed site, so the live +dashboard fetches them from the same origin instead of reaching for +``raw.githubusercontent.com``. diff --git a/sphinx/results.rst b/sphinx/results.rst new file mode 100644 index 00000000..18ee8838 --- /dev/null +++ b/sphinx/results.rst @@ -0,0 +1,70 @@ +Results +======= + +Combined DRC / LVS / ngspice status for every cell in the regression +matrix, built from the ``summary.json`` each runner writes. + +.. results-provenance:: + +.. tip:: + + These tables are generated when the documentation is built. For a run + that completed after the last deploy, see the + `live status dashboard `_. + +Verification matrix +------------------- + +.. verification-matrix:: + +A column appears only for a stage that actually ran for that PDK — which +is why gf180 has no ngspice column: simulation is enabled for sky130 +only. + +**Legend** — ✅ pass · ❌ fail · 💥 error (the runner itself failed, e.g. +a timeout) · ⏭️ skipped · — not run + +Cells are discovered, not registered: the sim runner intersects the +netlists in the DRC artifact with the testbenches in +``tests/sim/testbenches/``. A cell missing from this table has neither. + +ngspice measurements +-------------------- + +Every measurement ngspice reported, with the band from ``checks.json`` +and the resulting verdict. Values use the same engineering formatting as +the runner's console output, so a number here reads identically to one in +the log. + +.. ngspice-detail:: + :pdk: sky130 + +A dash in the limits column means no band was declared for that +measurement: it is recorded and displayed, but does not gate the build. A +one-sided band shows as ``— … 2n`` or ``24 … —``. + +Reproducing a result +-------------------- + +Each cell's assembled deck is written before ngspice runs, so any row +above can be re-run directly: + +.. code-block:: console + + ngspice -b sim_results/sky130/reports/sim//.deck.spice + +The deck is self-contained — model library, reference netlist and +testbench in one file — so it needs no arguments and no environment +beyond ngspice itself. + +Rebuilding these tables +----------------------- + +The build reads ``_results//summary.json`` relative to +``glayout_results_root``, which defaults to the repository root when a +local run exists and to the committed sample otherwise: + +.. code-block:: console + + sphinx-build -b html sphinx _site \ + -D glayout_results_root=/path/to/downloaded/artifacts diff --git a/sphinx/verification.rst b/sphinx/verification.rst new file mode 100644 index 00000000..ccc3174f --- /dev/null +++ b/sphinx/verification.rst @@ -0,0 +1,318 @@ +Verification +============ + +Every generator passes through three independent checks. They answer +different questions, and passing one says nothing about the others. + +.. list-table:: + :widths: 15 40 45 + :header-rows: 1 + + * - Stage + - Question it answers + - Tool + * - DRC + - Is the layout manufacturable under the PDK's rules? + - KLayout, using the PDK's own deck + * - LVS + - Does the layout implement the intended schematic? + - Magic (extract) + Netgen (compare) + * - ngspice + - Does the extracted netlist behave correctly? + - ngspice, on the DRC runner's reference netlist + +The stages are chained through artifacts rather than run by a single +driver. DRC runs first and emits the GDS plus a reference netlist per +cell; LVS and ngspice both consume that artifact, in parallel, so a flaky +LVS run does not block simulation. + +.. code-block:: console + + # DRC first — produces gds/, netlists/ and reports/ + python tests/drc/run_cell_drc.py --pdk sky130 --out-dir drc_results/sky130 + + # Then ngspice, against the netlists DRC emitted + python tests/sim/run_cell_sim.py \ + --pdk sky130 \ + --inputs-dir drc_results/sky130 \ + --out-dir sim_results/sky130 + + # A subset of cells + python tests/sim/run_cell_sim.py --pdk sky130 \ + --inputs-dir drc_results/sky130 --out-dir sim_results/sky130 \ + --cells diff_pair,opamp + +Each runner writes ``summary.json`` and ``junit.xml`` into its output +directory. Those summary files are what the tables on :doc:`results` are +built from. + +Where the flow lives +-------------------- + +.. code-block:: text + + tests/sim/ + ├── run_cell_sim.py # assembles decks, runs ngspice, reports + └── testbenches/ + ├── checks.json # measurement bands, keyed by cell + ├── current_mirror_nfet.spice + ├── current_mirror_pfet.spice + ├── diff_pair.spice + ├── diff_pair_ibias.spice + ├── flipped_voltage_follower.spice + ├── low_voltage_cmirror.spice + └── transmission_gate.spice + +.. note:: + + This directory was deleted in commit ``d57ca85`` and restored + afterwards. If ``tests/sim/`` is missing from your checkout, confirm + you are on a ``main`` that includes the restore commit. + +Design rule checking +-------------------- + +DRC runs KLayout against the PDK's own rule deck, so the rules are +exactly those the foundry ships — Glayout does not maintain a parallel +copy. A cell passes only with zero violations; there is no waiver +mechanism, because a generator that cannot produce a clean layout is a +bug in the generator. + +Violations are written as a KLayout marker database. Open it alongside +the GDS to see the flagged geometry in place: + +.. code-block:: console + + klayout .gds -m lvs_results/sky130//drc.lyrdb + +Each marker carries the rule name from the deck, which is the string to +look up in the PDK's rule documentation. + +.. list-table:: Common causes + :widths: 35 65 + :header-rows: 1 + + * - Symptom + - Usual cause + * - Minimum spacing on a metal layer + - Two subcells abutted without honouring the routing pitch + * - Enclosure / surround failure + - A via placed without the required overlap on one of its layers + * - Density or antenna rules + - Large routes without fill or a tie-down; usually top level only + * - Well / tap spacing + - Missing guard ring or tap on an isolated device + +Layout versus schematic +----------------------- + +LVS is two steps: Magic extracts a netlist from the layout, then Netgen +compares it against the reference schematic netlist. + +.. code-block:: text + + lvs_results///lvs.log # Netgen comparison output + lvs_results///extracted.spice + +Netgen reports mismatches in three flavours, and the distinction matters +when debugging: + +Device mismatch + The layout has a different count or type of device than the schematic. + Usually a generator emitting the wrong number of fingers, or a dummy + device that should not be electrically connected. + +Net mismatch + Device counts agree but connectivity differs. Typically a route that + did not land on the intended port, or two nets shorted through a + shared tap. + +Property mismatch + Connectivity is correct but W/L or multiplier values differ. Often a + unit error — Glayout works in microns, and SPICE decks frequently use + metres. + +.. warning:: + + ``opamp`` currently fails LVS on gf180 with a net mismatch. The sky130 + variant passes. It is recorded in the results file as a known issue so + it renders distinctly from a new regression. + +For LVS to have anything to compare against, a generator needs a +reference netlist with matching port names. Composite cells should expose +ports in the same order as the schematic subcircuit definition: + +.. code-block:: text + + .subckt diff_pair vin_p vin_n vout_p vout_n vbias vss + ... + .ends + +Mismatched port *order* still passes LVS if names match, but produces +confusing testbench wiring later, so keep them aligned. + +ngspice regression +------------------ + +Simulation checks that a cell behaves as intended, which neither DRC nor +LVS can tell you: a layout can be manufacturable and topologically +correct and still miss its timing or bias targets. + +.. important:: + + This is a **pre-layout / functional** check by default. It simulates + the reference netlist that the DRC runner emits, not a + parasitic-extracted one. Post-layout (PEX) simulation is a documented + extension point in ``_assemble_deck`` — it is off the default path + because per-cell magic extraction is slow in CI and, on gf180, + mis-extracts the substrate. + +A cell is simulated when it has **both** a reference netlist from the DRC +artifact and a testbench: + +.. code-block:: text + + drc_results//netlists/.spice <- from run_cell_drc.py + tests/sim/testbenches/.spice <- hand-written + +Cells with only one of the two are skipped silently, so adding a +testbench is all it takes to bring a cell into the matrix. + +How a cell gets simulated: + +1. ``_assemble_deck`` writes a self-contained deck: the PDK model + library at the right corner, then the reference netlist, then the + testbench body. +2. ngspice runs it in batch mode (``-b``, no ``.control`` block — that + combination causes a well-known double execution). +3. ``_parse_sim_log`` extracts ``.measure`` results and compares each + against its band. + +Writing a testbench +~~~~~~~~~~~~~~~~~~~ + +A testbench is **stimulus, analysis and ``.measure`` cards only**. It must +not declare the model ``.lib`` or ``.include`` the DUT netlist — the +runner injects both, which is what lets one testbench work across corners +and PDKs. Instantiate the DUT with a subckt call whose name matches the +``.subckt `` in the reference netlist: + +.. code-block:: text + + 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 + +A trailing ``.end`` is stripped by the runner, so leaving one in is +harmless. + +Declaring pass criteria +~~~~~~~~~~~~~~~~~~~~~~~ + +Bands live in one consolidated file, ``tests/sim/testbenches/checks.json``, +keyed by cell then by measurement name. Either bound may be omitted for a +one-sided limit: + +.. code-block:: json + + { + "diff_pair": { + "gain_db": { "min": 24.0, "max": 28.0 }, + "tphl": { "max": 2e-9 } + }, + "current_mirror_nfet": { + "iout": { "max": 4.2e-6 }, + "vout_min": { "max": 0.40 } + } + } + +A per-cell sidecar at ``tests/sim/testbenches/.checks.json`` is used +as a fallback for any cell not listed in the consolidated file. Override +the consolidated path with ``--checks-file``. + +Without any band, a cell still runs as a smoke test: it passes when +ngspice finishes cleanly with no failed ``.measure``. Each measurement +gets one of four verdicts: + +``PASS`` + Measured and inside its band. + +``FAIL`` + Measured and outside its band. Fails the cell. + +``MISSING`` + A band was declared but no matching measurement appeared in the log — + usually a ``.measure`` name typo, or an analysis that did not converge + far enough to emit it. Fails the cell. + +``no band`` + Measured, with no band declared. Recorded and displayed, but does not + gate the build. + +Failure modes the parser catches +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Convergence problems are treated as failures rather than missing data, +because a silently truncated run otherwise reads as a pass: + +* ``fatal`` errors reported by ngspice +* singular matrix +* timestep too small +* aborted or interrupted simulation +* iteration limit reached + +Environment problems are surfaced separately, so a broken container reads +as its actual cause rather than a generic "sim inconclusive": + +.. list-table:: + :widths: 45 55 + :header-rows: 1 + + * - Reported conclusion + - Usual cause + * - ngspice binary not on PATH + - ngspice missing from the runner image + * - missing include / model lib + - ``PDK_ROOT`` unset, or the model library moved + * - missing model / unresolved subckt + - Wrong corner section, or the testbench's subckt call does not + match the ``.subckt`` name in the reference netlist + +The measurement scan is scoped to ngspice's "Measurements for … Analysis" +blocks. Without that scoping, the end-of-run resource report (``Stack = 0 +bytes.``) parses as a measurement. + +Reading the output +~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + sim_results// + ├── summary.json # every cell, every measurement + ├── junit.xml # published as a CI check + └── reports/sim// + ├── .deck.spice # the assembled deck + └── .log # raw ngspice output + +The deck is written before ngspice runs, so a failing cell leaves behind +exactly the file you need to reproduce it: + +.. code-block:: console + + ngspice -b sim_results/sky130/reports/sim/opamp/opamp.deck.spice + +In CI the same measurement tables are also written to the run's Summary +page via ``$GITHUB_STEP_SUMMARY``, so a failure is readable without +downloading artifacts. + +Adding a cell to the matrix +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Add ``tests/sim/testbenches/.spice``. +2. Optionally add bands for it to ``checks.json``. + +The runner discovers cells by intersecting the netlists in the DRC +artifact with the testbenches on disk, so no registry needs updating. A +cell with a testbench but no netlist is skipped, and vice versa. diff --git a/tools/render_results.py b/tools/render_results.py new file mode 100644 index 00000000..16d7bf2d --- /dev/null +++ b/tools/render_results.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Render verification tables into marker-delimited blocks in Markdown files. + +The Sphinx site generates its tables directly from the runners' ``summary.json`` +via ``sphinx/_ext/glayout_results.py``. Plain Markdown files such as +``README.md`` cannot run directives, so this script writes Markdown tables into +comment-delimited blocks instead: + + + ... + + +Only the text between a matching pair is replaced, so surrounding prose is +never touched. + +Reads the same layout the docs do, relative to --results-root: + + drc_results//summary.json + lvs_results//summary.json + sim_results//summary.json + +Usage +----- + python tools/render_results.py --results-root . --target README.md + + # verify a target is current without writing (exit 1 if stale) + python tools/render_results.py --results-root . --target README.md --check +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +STAGES = ("drc", "lvs", "sim") +STAGE_LABELS = {"drc": "DRC", "lvs": "LVS", "sim": "ngspice"} + +STATUS_MAP: dict[str, str] = { + "pass": "\u2705 Pass", + "fail": "\u274c Fail", + "error": "\U0001f4a5 Error", + "skip": "\u23ed\ufe0f Skipped", + "missing": "\u2014 Not run", +} + +VERDICT_MAP: dict[str, str] = { + "PASS": "\u2705", + "FAIL": "\u274c", + "MISSING": "\u26a0\ufe0f", + "n/a": "\u2014", +} + + +def fmt_eng(x: Any) -> str: + """Engineering notation, matching run_cell_sim.py's _fmt_eng.""" + if x is None: + return "\u2014" + if not isinstance(x, (int, float)): + return str(x) + 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}" + + +def fmt_band(row: dict) -> str: + lo, hi = row.get("min"), row.get("max") + if lo is None and hi is None: + return "\u2014" + return f"{fmt_eng(lo)} \u2026 {fmt_eng(hi)}" + + +def load(root: Path, pdks: list[str]) -> dict[tuple[str, str], dict]: + store: dict[tuple[str, str], dict] = {} + for stage in STAGES: + for pdk in pdks: + path = root / f"{stage}_results" / pdk / "summary.json" + if not path.exists(): + continue + try: + store[(stage, pdk)] = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"warning: {path}: {exc}", file=sys.stderr) + return store + + +def cells_in(store: dict) -> list[str]: + seen: set[str] = set() + for data in store.values(): + for record in data.get("results") or []: + if record.get("cell"): + seen.add(record["cell"]) + return sorted(seen) + + +def render_matrix(store: dict, pdks: list[str]) -> str: + columns = [(s, p) for p in pdks for s in STAGES if (s, p) in store] + if not columns: + return "_No runner output available._" + + lookup = { + key: {r.get("cell"): r for r in (store[key].get("results") or [])} + for key in columns + } + + header = "| Cell | " + " | ".join( + f"{STAGE_LABELS[s]}
{p}" for s, p in columns + ) + " |" + sep = "|------|" + "|".join([":---:"] * len(columns)) + "|" + + lines = [header, sep] + for cell in cells_in(store): + row = [f"`{cell}`"] + for key in columns: + record = lookup[key].get(cell) + row.append(STATUS_MAP.get( + record.get("status", "missing") if record else "missing", + STATUS_MAP["missing"], + )) + lines.append("| " + " | ".join(row) + " |") + return "\n".join(lines) + + +def render_detail(store: dict, pdk: str) -> str: + data = store.get(("sim", pdk)) + if not data: + return f"_No ngspice run recorded for {pdk}._" + + lines = [ + "| Cell | Measurement | Value | Limits | Result |", + "|------|-------------|-------|--------|--------|", + ] + for record in data.get("results") or []: + cell = record.get("cell", "\u2014") + rows = (record.get("summary") or {}).get("rows") or [] + if not rows: + if record.get("status") in ("fail", "error"): + message = (record.get("message") or "")[:70] + lines.append( + f"| `{cell}` | \u2014 | \u2014 | \u2014 | " + f"{STATUS_MAP.get(record['status'], '')} {message} |" + ) + continue + for measurement in rows: + lines.append( + f"| `{cell}` | `{measurement.get('name', '')}` | " + f"{fmt_eng(measurement.get('value'))} | {fmt_band(measurement)} | " + f"{VERDICT_MAP.get(measurement.get('verdict', 'n/a'), '')} " + f"{measurement.get('verdict', '')} |" + ) + if len(lines) == 2: + return "_No measurements captured._" + return "\n".join(lines) + + +def render_summary(store: dict, pdks: list[str]) -> str: + lines = ["| Stage | " + " | ".join(pdks) + " |", + "|-------|" + "|".join(["---"] * len(pdks)) + "|"] + any_row = False + for stage in STAGES: + if not any((stage, p) in store for p in pdks): + continue + any_row = True + row = [STAGE_LABELS[stage]] + for pdk in pdks: + data = store.get((stage, pdk)) + if not data: + row.append(STATUS_MAP["missing"]) + continue + total = data.get("total", 0) + if not total: + row.append("\u2014 nothing to run") + continue + failed = data.get("fail", 0) + data.get("error", 0) + symbol = "\u274c" if failed else "\u2705" + row.append(f"{symbol} {data.get('pass', 0)}/{total}") + lines.append("| " + " | ".join(row) + " |") + return "\n".join(lines) if any_row else "_No runner output available._" + + +def replace_block(text: str, name: str, body: str) -> tuple[str, bool]: + pattern = re.compile( + rf"()(.*?)" + rf"()", + re.DOTALL, + ) + if not pattern.search(text): + return text, False + return pattern.sub(lambda m: f"{m.group(1)}\n\n{body}\n\n{m.group(3)}", text), True + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--results-root", type=Path, default=Path("."), + help="directory containing _results//summary.json") + parser.add_argument("--pdks", default="sky130,gf180") + parser.add_argument("--sim-pdk", default="sky130", + help="PDK whose measurements fill the detail table") + parser.add_argument("--target", required=True, action="append", type=Path) + parser.add_argument("--check", action="store_true", + help="do not write; exit 1 if any target is stale") + args = parser.parse_args(argv) + + pdks = [p.strip() for p in args.pdks.split(",") if p.strip()] + store = load(args.results_root, pdks) + if not store: + print(f"error: no summary.json under {args.results_root}", file=sys.stderr) + return 2 + + renderers = { + "VERIFICATION_MATRIX": lambda: render_matrix(store, pdks), + "NGSPICE_RESULTS": lambda: render_detail(store, args.sim_pdk), + "CI_SUMMARY": lambda: render_summary(store, pdks), + } + + stale = False + for target in args.target: + if not target.exists(): + print(f"error: target not found: {target}", file=sys.stderr) + return 2 + + original = target.read_text(encoding="utf-8") + updated = original + found: list[str] = [] + for name, render in renderers.items(): + updated, ok = replace_block(updated, name, render()) + if ok: + found.append(name) + + if not found: + print(f"warning: no marker blocks in {target}", file=sys.stderr) + continue + if updated == original: + print(f"{target}: up to date ({', '.join(found)})") + elif args.check: + print(f"{target}: STALE ({', '.join(found)})", file=sys.stderr) + stale = True + else: + target.write_text(updated, encoding="utf-8") + print(f"{target}: updated ({', '.join(found)})") + + return 1 if stale else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/live/config.js b/web/live/config.js new file mode 100644 index 00000000..fc26e939 --- /dev/null +++ b/web/live/config.js @@ -0,0 +1,15 @@ +/* Where the live dashboard fetches runner output from. + * + * It expects /_results//summary.json for each stage + * (drc, lvs, sim) and PDK. Missing files are skipped, so a PDK without + * simulation simply shows no ngspice column. + * + * The docs workflow rewrites this file at deploy time so the base tracks the + * repository it was built from, and run_webpage.sh rewrites the staged copy to + * point at locally served results. + */ +window.GLAYOUT_LIVE = { + resultsBase: + "https://raw.githubusercontent.com/ReaLLMASIC/gLayout/main/", + pdks: ["sky130", "gf180"] +}; diff --git a/web/live/index.html b/web/live/index.html new file mode 100644 index 00000000..7e7f5943 --- /dev/null +++ b/web/live/index.html @@ -0,0 +1,416 @@ + + + + + +glayout — live verification status + + + +
+

glayout — live verification status

+

+ Fetched from on load, so this reflects the latest + pipeline run rather than the last docs deploy. + Back to documentation +

+ +
+ + Loading… +
+ +
+ +
+
+ + Auto-refreshes every 2 minutes while this tab is visible. +
+
+
+ + + + + From d89b5da99008f81fa33300a4d3b0d5e50782de48 Mon Sep 17 00:00:00 2001 From: Nimish Kapoor Date: Sat, 15 Aug 2026 16:56:18 +0530 Subject: [PATCH 7/7] ci: drop pip cache from docs workflow (repo has no pyproject.toml) --- .github/workflows/docs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 89299bde..2b3e75e0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -37,7 +37,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' - cache: pip - name: Install dependencies run: |