Analog Layout Optimization PoC — without Virtuoso
A working, fully-tested proof of concept for an analog design + layout flow that runs and verifies entirely offline — no running Cadence Virtuoso, no license needed. It now spans the whole loop, schematic to silicon:
sizing (DE) → schematic netlist → placement (SA) → routing (negotiated, multi-layer) → sign-off (DRC + LVS + connectivity) → parasitic extraction → post-layout re-sim → optional SKY130 silicon verify (real PDK)
It demonstrates which layers of an analog flow can be built and validated before
any commercial EDA tool is in the loop, using virtuoso-bridge-lite's pure SKILL
builders, plus open-source ngspice and the open SKY130 PDK for real
device physics. 140+ tests, all green offline (SKY130/Spectre paths degrade
gracefully when the PDK/server is absent).
See End-to-end flow & web app for the one-click pipeline and the interactive UI.
- Quick start
- Screens
- Recommended architecture
- End-to-end flow & web app
- The cell
- What is verified offline (and how)
- What is deferred to optional future Virtuoso access
- Run it
- Can a surrogate replace Virtuoso? (
surrogate_*,truth.py) - Routing optimization (
routing.py) - Joint device + routing co-optimization (
joint.py) - A harder circuit + autonomous algorithm study (
opamp*.py) - Connecting a real PDK / Spectre (
spectre_backend.py,pdk.py) - Going live later
Runs fully offline — no Virtuoso, PDK or license needed.
git clone https://github.com/kos2001/analog-layout-optimizer && cd analog-layout-optimizer
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt -r webapp/backend/requirements.txt
# backend (FastAPI) → http://127.0.0.1:8011/api/health
python -m uvicorn webapp.backend.main:app --port 8011
# frontend (Vite dev server, proxies /api → :8011), in a second terminal
cd webapp/frontend && npm install && npm run dev # http://localhost:5173The test suite (python -m pytest -q, 150+ offline tests) additionally needs
virtuoso-bridge-lite installed — see Run it.
Open the UI, pick Full flow in the sidebar and press Run full flow: the whole sizing → placement → routing → sign-off → post-layout pipeline runs in well under a second and shows a stage-by-stage verdict. See Run the web app and Batch mode for options.
This repo is offline-first: it runs today without an EDA server, Virtuoso,
PDK, or license. It is the analog optimization application layer. Arcadia
virtuoso-bridge-lite stays
underneath as the optional Cadence runtime engine for a future live setup.
Hermes Agent / Web UI / FastAPI
|
v
analog-layout-optimizer application layer
- geometry/routing/problem models
- PDK config + netlist templates
- optimizers and surrogate-assisted loops
- visualization and Hermes workflows
|
v
Arcadia virtuoso-bridge-lite engine
- VirtuosoClient SKILL execution
- SSH/jump-host/local bridge lifecycle
- SpectreSimulator + PSF parsing
- Maestro/window/snapshot diagnostics
|
v
Optional future: remote/local Cadence Virtuoso + Spectre + PDK
Boundary: keep design objectives, candidate generation, surrogate logic, UI, and
customer-specific PDK configuration in this repo. Offline mode is the default and
fully supported. If a live Cadence environment exists later, delegate SKILL
transport, daemon/tunnel lifecycle, Spectre invocation, PSF parsing, and
Virtuoso/Maestro diagnostics to virtuoso-bridge-lite; do not grow a second
bridge here. See docs/arcadia-integration.md for the operating checklist.
A FastAPI backend (webapp/backend) + React/Vite frontend (webapp/frontend)
expose every stage. The Full flow tab runs the whole pipeline on one click
(/api/full-flow → layout_opt/flow_e2e.py) and shows a stage-by-stage verdict;
the other tabs let you explore each stage interactively.
The frontend is a tool-style workspace:
- Sidebar views grouped by stage (Flow / Layout / Analysis / Integration),
deep-linkable as
#/<view>. - Run manager (bottom dock,
⌘J): every optimizer, flow and simulator run goes through/api/jobsand is listed with status, per-stage timing bars, queue/run time and cache hits. Full flow streams its stages live over SSE. - API console: every request from the window, with status and latency.
- Command palette (
⌘K) for views and actions;⌘Btoggles the sidebar. - Status bar: backend connectivity, engine fingerprint, compute pool and
result-cache hit rate from
/api/health.
┌ sizing (DE, log-space) ── opamp_opt.py minimize power s.t. gain/GBW/PM/slew
│ schematic (netlist) ───── schematic.py OTA devices+terminals = single source of truth
│ placement (SA) ────────── placement.py minimize HPWL (+ overlap)
│ routing (negotiated) ──── mlroute.py 2-layer PathFinder rip-up-and-reroute
│ sign-off ──────────────── drc.py + signoff.py DRC + LVS (extract vs netlist) + connectivity
│ post-layout ───────────── parasitics.py extract R/C → re-sim → ΔPM
└ silicon verify (opt.) ─── ngspice_backend.py real SKY130 BSIM via ngspice
| Web tab | What it shows |
|---|---|
| Full flow | one-click sizing→silicon pipeline + verdict |
| Layout / Joint | device + interconnect geometry optimization |
| Comparator (maze) | A* maze routing + drag-to-place live re-routing |
| Complex cases | bus/macro+power-grid/diff-pair; fixed vs best-order vs PathFinder + DRC |
| Schematic → P&R | netlist → placement → routing, sign-off panel + DRC overlay + post-layout |
| PPA | NSGA-II power/performance/area Pareto front + preference weighting |
| Op-amp (OTA) | sizing + AC Bode + Verify (generic level-1 / SKY130 real PDK / Spectre) |
| T-coil, Process, Surrogate, Bridge, Agent | bandwidth peaking, foundry-change effects, surrogate loop, bridge smoke, Hermes agent |
# backend (set PDK_ROOT to enable the SKY130 path; optional)
PDK_ROOT=~/pdk python -m uvicorn webapp.backend.main:app --port 8011
# frontend (Vite dev server proxies /api → :8011)
cd webapp/frontend && npm install && npm run dev # http://localhost:5173/api/full-flow?place=sa&seed=1&sky130=false returns the staged report headless.
The same flow runs headless the way a commercial P&R / sign-off tool runs in a regression farm: a JSON config (plus CLI overrides) in, a self-contained run directory out, and an exit status that gates CI.
python -m layout_opt defaults > flow.json # starting config
python -m layout_opt flow -f flow.json -o runs/ota_tt
python -m layout_opt flow --seeds 0,1,2,3 --klayout-drc -o runs/ota_sweepruns/ota_tt/
flow.json effective config + revision/python/platform (reproducibility)
logs/run.log timestamped log with message IDs (ALO-001 … ALO-900)
reports/qor.rpt verdict, stage table, per-stage wall / CPU / peak RSS
reports/{sizing,placement,route,drc,lvs,postlayout}.rpt
outputs/design.json full flow database (same payload as /api/full-flow)
outputs/two_stage_ota.gds
Exit status: 0 sign-off PASS, 1 FAIL (including a dirty --klayout-drc),
2 bad configuration or internal error. Stage timing comes from
layout_opt/runlog.py; with stage("name"): is a no-op outside a batch run.
scripts/bench_engines.py times the router, DRC and placer on synthetic designs
of increasing size (--quick, --json out.json). Kernel speedups are required to
be result-preserving — tests/test_engine_perf.py checks them against reference
implementations. Measured on the same machine before → after the optimization pass:
| Case | Before | After |
|---|---|---|
| Negotiated route, 80×80×2 grid, 60 nets | 24.1 s | 11.1 s |
| DRC via spacing, 10 000 vias | 5.6 s | 0.04 s |
| SA placement, 20 000 iterations | 0.86 s | 0.15 s |
run_flow (OTA place → route → sign-off) |
0.18 s | 0.06 s |
Known limit: on congested grids ≥ 120×120 with 120+ nets the negotiated router does not converge within its iteration budget and falls back to sequential routing (minutes, with opens) — the next scaling target.
scenarios.py + mlroute.py answer this with running comparisons. A* is optimal
per net, but routing nets sequentially makes the result depend on net order
(NP-hard). On congested cases fixed-order A* strands nets or burns vias;
PathFinder negotiated congestion (rip-up-and-reroute, order-independent) on a
2-layer surface routes them all. E.g. the macro+power-grid case: fixed-order
leaves a net unrouted; negotiation routes all 8 cleanly.
The SA placer (placement.py, analog_aware=True) adds the two layout concerns
that dominate op-amp quality: device matching — matched pairs (input pair
M1/M2, mirror M3/M4) are pulled into a symmetric, abutted placement (offset/CMRR
and gain-error come from random pair offsets) — and critical-node parasitics
— the high-impedance gain nets (n1/n2), where parasitic C costs the most phase
margin, are weighted to stay short. Schematic→P&R tab → Analog-aware
placement, or alo.py signoff --analog.
Effect vs plain HPWL placement (seed 0): symmetry penalty 37 → 2.5, critical-net WL 31 → 17, and post-layout PM 48.7° → 59.2°. It's a real matching-vs-routability trade-off — tighter matching can leave a net to reroute (the router turns that into an honest open, flagged by sign-off).
drc.py checks the routed geometry (short/corner/via-spacing/open); signoff.py
runs LVS — it extracts per-net connectivity from the routed metal and verifies
it matches the schematic netlist (terminals on one component = no open; no shared
cell = no short) — then gives a PASS/FAIL verdict. parasitics.py then extracts
R/C and re-simulates: parasitic C on the output and on the high-impedance internal
node degrade phase margin. A tighter (SA) placement degrades far less than a
random one — the concrete link from placement quality → silicon performance.
The placed+routed flow exports to real GDSII (gdstk, no PDK needed) on
SKY130 stream layers (met1 68/20, met2 69/20, via 68/44, device 65/20, labels) —
each routing-grid cell becomes metal, vias where a net changes layer, a marker +
label per device. The file opens in KLayout / Magic and can be DRC'd against
the SKY130 deck — the bridge from the in-house grid sign-off to real-tool
verification. Schematic→P&R tab → Export GDS, or alo.py gds.
The exported GDS is checked by KLayout's actual geometric DRC engine (pip
klayout, no GUI): met1/met2 min-width and min-spacing with SKY130 rules
(0.14 µm). It cross-validates the in-house grid DRC — both flag the same
corner/notch regions, now at true geometry. Schematic→P&R tab → Real DRC
(KLayout), alo.py klayout-drc, or /api/flow/drc-klayout.
Beyond the grid: device_layout.py synthesizes real SKY130 transistor
geometry (active / poly / licon / li1 / mcon / met1, nwell for PMOS, implants)
— a merged-diffusion current mirror with proper contacts and a poly-tied gate.
klayout_lvs.py runs real LVS with KLayout's engine: DeviceExtractorMOS3
recovers the MOSFETs (type, W, L, connectivity) and NetlistComparer matches
the extracted layout netlist against the schematic — a true layout-vs-schematic
check, and it correctly fails on a wrong netlist or wrong device width.
Schematic→P&R tab → Transistor LVS (KLayout), alo.py lvs, or /api/lvs.
Magic + Netgen aren't installable on this host (no conda/brew formula; building from source is out of scope), so KLayout's LVS engine — the same one the SKY130 KLayout LVS deck uses, and pip-installable — is the equivalent real-tool path.
Full OTA, transistor level (ota_layout.py): all seven MOSFETs are laid out
with per-device real sizing — each W comes from the OTA sizing (W/L × L,
min-clamped), drawn multi-finger so wide devices (M6 ≈ 15 µm → 6 fingers)
stay compact (KLayout merges the fingers back to the full W). Routing is
three-layer (met1 terminals, met2 risers, met3 per-net buses + vias); the
result is met1/met2/met3 DRC-clean (width + spacing) and LVS-clean —
4 nmos + 3 pmos, every net matched, each device's W verified. alo.py lvs --cell ota, /api/lvs?cell=ota, or the Transistor LVS (KLayout) button;
the transistor-level GDS exports too. Cc is laid out as a real MIM cap
(capm top plate / met2 bottom plate) and extracted with KLayout's capacitor
extractor, so the LVS is the full 8-device network (4 nmos + 3 pmos + Cc ≈
117 fF, n2↔VOUT). The whole layout — including the cap — is met1/met2/met3
DRC-clean, and renders in the Layout view tab.
Common-centroid input pair (diffpair_cc.py): the matching-critical input
pair M1/M2 is laid out as an ABBA interdigitated finger array — both devices
share one centroid, so a linear process gradient contributes equally to each
and cancels (gradient mismatch 0 vs 0.5 for naive segregated AABB). Each
finger is a real device; the array re-combines on extraction into two matched
nmos (W = nf·wf), and a p+ guard ring frames the cell (tied into TAIL — pure
substrate contact, no extra device pin, so LVS still matches). The result is
LVS-clean and met1/met2/met3 DRC-clean with the guard ring on or off.
alo.py lvs --cell diffpair, /api/lvs?cell=diffpair, or the Diff pair
(common-centroid) button in the Layout view.
Dummy fingers + LDE mismatch (diffpair_cc.py, lde.py): common-centroid
cancels the linear gradient but not the STI/LOD edge effect — in an ABBA
block the two outer fingers belong to one device, so it carries more trench
stress. lde.py models the per-device threshold shift from STI/LOD (gate-to-OD
distance) and well-proximity (WPE), and the dummy fingers added at each end of
the array (dummies=, tied off to TAIL, LVS-clean as one merged off device) push
every active finger interior: the input-pair LOD mismatch drops 20.9 mV → 1.8 mV
(1 dummy/side) → 0.6 mV (2). alo.py lde, /api/lde.
Antenna + EM sign-off (antenna.py, em.py): two more foundry-style checks
on the extracted transistor layout. Antenna accumulates per-gate-net metal
area over gate-oxide area (met1→met1+met2→+met3) and flags any net over the ratio
limit — worst on the OTA is the small high-Z node n1 (~22, well under 400).
EM takes each net's DC current from the sizing (VDD/VSS = I_tail + I6, tail,
mirror branches) and the narrowest routed metal from the layout, and checks
current density vs J_max — the power rails run at ~50 % on minimum-width metal.
alo.py antenna / em, /api/antenna / /api/em, or the Antenna + EM button
in the Schematic→P&R tab.
The OTA Verify can run on real SkyWater SKY130 BSIM devices
(sky130_fd_pr__{n,p}fet_01v8) through the open PDK's ngspice .lib, instead of
the analytic square-law model. Same sizing, different (silicon-grade) numbers —
the model-fidelity gap that motivates post-layout sim. Install the PDK with
volare enable and point PDK_ROOT at it; without it the path degrades to a
clear "PDK missing" and the live tests skip.
A textbook differential pair: two transistors as 2·nf interdigitated vertical
poly fingers over a shared diffusion, wrapped in a guard ring.
guard ring (M1)
+-----------------------------+
| | | | | | | | | poly | 2·nf fingers
| [== OD diffusion ==] |
+-----------------------------+
The optimizer searches 5 continuous geometric parameters
(w_finger, L, finger_pitch, guard_gap, gr_width) to minimize bounding-box
area subject to:
- DRC floors — min L / min W / min poly pitch / min guard gap / min ring width
- a drive-strength spec —
nf · w_finger ≥ w_min_total
| Layer | Module | Verified by |
|---|---|---|
| Parameterized geometry generation | generator.py, geometry.py |
shape count, closed-form bbox/area (test_generator.py) |
| SKILL emission (the bridge call surface) | skill.py |
string-identical to virtuoso_bridge's own layout_create_rect (test_skill_emit.py) |
| Geometric DRC + spec checks | evaluate.py |
per-rule penalty math (test_evaluate.py) |
| Optimization loop | optimize.py |
converges to the hand-derived analytic optimum (test_optimize.py) |
The optimum is analytically known (every parameter is pushed to its binding lower bound), so the test suite proves generator + evaluator + optimizer are jointly correct — without Virtuoso ever running.
No EDA server is required for this repo's current operating mode. If a live Cadence environment becomes available later, it would be used for:
- Whether the emitted SKILL produces valid geometry in the real PDK
- Real DRC / LVS against the PDK deck
- Parasitic extraction (PEX) and post-layout performance
These are intentionally not modeled. The surrogate evaluate() has the same
(params → scalar) signature as a future Virtuoso/Spectre backend, so swapping
it in is a one-function change — see the bottom of run_demo.py.
# from repo root, with Arcadia virtuoso-bridge-lite cloned adjacent to this repo
uv pip install -e ../virtuoso-bridge-lite numpy scipy scikit-learn pytest
python -m pytest -q # 150+ tests, all offline (SKY130 live tests skip if no PDK)
python run_demo.py # device demo: optimize → report → emit SKILL
# full schematic→silicon pipeline, headless:
python -c "from layout_opt.flow_e2e import run_end_to_end as r; \
import json; print(json.dumps(r('sa',1)['stages'], indent=1))"Arcadia engine smoke checks (safe without an EDA server):
python verify_bridge.py
python hermes/analog-layout-optimizer/scripts/alo.py bridge-smoke
# optional future only, when a real bridge is configured and started:
python hermes/analog-layout-optimizer/scripts/alo.py bridge-smoke --liveDemo output: converges in ~15k evals to area 2.1010 µm², DRC-clean, then
prints the 13 dbCreateRect(...) SKILL commands that would build the cell.
python run_surrogate_demo.py answers this directly. It models an expensive
post-layout figure of merit (a gain proxy gm·ro ~ √(W_total·L) degraded by
layout parasitics — truth.py, the Spectre+PEX stand-in) and runs
surrogate-assisted optimization:
- Sample a few designs, evaluate the ground truth → train a GP surrogate
(
surrogate.py). - Minimize area s.t. DRC + drive-spec + surrogate-predicted FoM ≥ target,
using thousands of cheap surrogate calls (
surrogate_opt.py). - Validate the proposed optimum against the ground truth (one expensive call), record surrogate-vs-truth error, add the point, retrain. Repeat.
Observed (target FoM 3.5):
| value | |
|---|---|
| pure area optimum | 2.101 µm², FoM 2.15 — fails the gain target |
| surrogate-assisted optimum | 2.186 µm², FoM 3.66 (truth-verified ✓) |
| prediction error at optimum | 0.70 → 0.001 over 6 rounds |
| ground-truth calls | 30 |
| surrogate calls | 18,450 (≈615× fewer expensive evaluations) |
Takeaways:
- The surrogate does replace Virtuoso/Spectre inside the search loop — that is where the 615× saving comes from.
- It does not replace ground truth for trust: round 1 the surrogate predicted 3.52 but the truth was 3.31 — the validation loop caught the over-optimistic point and retraining corrected it.
- Sign-off (real DRC/LVS/PEX) still needs the tool. A surrogate is an accelerator, not a substitute for the physical verdict.
This is the same swap-the-evaluate-backend idea: here the loop's expensive
term is served by a learned model plus periodic ground-truth validation.
python run_routing_demo.py optimizes the interconnect, not just the
device. A structured router connects the interdigitated fingers to net rails
(gates → VINP/VINN, drains → VOUTN/VOUTP, sources → VTAIL) with horizontal
metal rails, vertical stubs, and vias — then searches the routing parameters
(rail width / pitch / via size) to minimize wirelength + metal area subject
to metal DRC (min width / spacing / via enclosure) and full connectivity.
Observed (fixed device geometry):
| wirelength | metal area | DRC | |
|---|---|---|---|
| loose default | 22.26 µm | 4.452 µm² | clean |
| optimized | 13.07 µm | 1.059 µm² | clean |
| −41% | −76% | connectivity preserved |
Scope: this is a structured router for the diff pair's regular topology, not a
general maze/channel router. The DRC-clean optimum is analytically known
(via→floor, rail_width→max(min_width, via+2·enclosure), pitch→width+spacing), so
test_routing.py verifies the optimizer reaches it. Real routing parasitics
(PEX) would feed the same surrogate-assisted loop above.
python run_joint_demo.py searches the device geometry (5 params) and the
interconnect (3 params) together, minimizing the full routed-cell area
subject to device DRC + drive-spec + routing DRC + connectivity.
The point: optimizing the device alone ignores that rails stack above/below the active area, so it underestimates the real cell.
| area | |
|---|---|
| device-only optimum | 2.101 µm² (interconnect ignored) |
| joint optimum, total cell | 2.790 µm² (device + routing) |
| the real cell is ~33% larger than the device-only estimate |
All 8 parameters converge to their DRC/spec floors, the result is DRC-clean on
both device and metal, drive-spec is met, and connectivity holds —
verified in test_joint.py.
python run_opamp_experiments.py sizes a two-stage Miller-compensated OTA
(8 parameters: W/L of M1/M3/M5/M6/M7, Itail, I6, Cc) — minimize power subject to
gain ≥ 80 dB, GBW ≥ 50 MHz, phase margin ≥ 65°, slew ≥ 50 V/us, and every device
in saturation. Only ~2.7% of the search box is feasible, so it is a genuinely
hard constrained problem. The study runs several optimizers across seeds, then
diagnoses and fixes the baseline's weakness:
| strategy | best | mean | std | note |
|---|---|---|---|---|
| random search | 0.846 mW | 1.04 | 0.123 | reference floor |
| DE, linear space (baseline) | 0.250 | 0.318 | 0.063 | high seed-to-seed variance |
| DE, log space | 0.101 | 0.113 | 0.009 | 2.8× lower power, 7× tighter |
| DE log + SLSQP refine | 0.100 | 0.112 | 0.009 | marginal further gain |
Diagnosed improvement points (found from the data, not assumed):
- Baseline DE beats random search but its per-seed power scatters widely. Cause: parameters span decades (I: 1 µA–1 mA, Cc: 0.1–10 pF), so linear-space DE mutations under-resolve the small-magnitude knobs.
- Fix — optimize in log space: 2.8× lower mean power and 7× tighter variance. This is the dominant lever.
- A local SLSQP polish on top adds only a marginal gain once log-space DE is used.
The found optimum sits exactly on the binding constraints (Vov, PM, gain at
their floors) — the signature of a correct min-power solution. The model
(opamp.py) is a documented square-law analytical surrogate; a real flow would
swap it for Spectre, leaving the optimizer experiments unchanged.
The Spectre backend swaps the analytical OTA model for a real Spectre AC run
driven through virtuoso-bridge, keeping the identical OpAmpParams -> OpAmpSpecs
contract (so the optimizer/surrogate/study code is unchanged). gain/GBW/PM come
from the AC sweep; power/slew/overdrives stay exact-given-sizing.
Connecting a process = filling one PDKConfig (pdk.py) — no code changes:
from layout_opt.pdk import PDKConfig
MY_PDK = PDKConfig(
name="tsmcN28",
model_include='include "/pdk/models/spectre/toplevel.scs" section=tt',
nmos="nch_mac", pmos="pch_mac", l_um=0.03, vdd=0.9,
)Go-live steps:
virtuoso-bridge init user@eda-server [-J jump]thenvirtuoso-bridge start(SSH tunnel + Spectre); confirm withvirtuoso-bridge statusandpython -c "from layout_opt.spectre_backend import preflight; print(preflight())".- Evaluate one design against the PDK:
spectre_evaluate(params, MY_PDK)→ measuredOpAmpSpecs. - Optimize with Spectre as ground truth: feed
make_spectre_objective(MY_PDK)to the optimizer, and usesurrogate_opt.pyto keep Spectre calls few (the PoC shows ~600x fewer expensive evals).
The full pipeline (render → run → parse → specs) is verified offline against a
synthesized SimulationResult (test_spectre_backend.py::…full_pipeline…); only
the actual silicon-accurate run needs a licensed server + PDK. When not
connected, the backend raises SpectreUnavailable with actionable guidance.
Via Hermes, the agent drives this with alo.py preflight and
alo.py spectre-eval [--model-include … --nmos … --pmos …].
When a Virtuoso server is reachable (e.g. a university EDA host via the bridge's
remote mode), replace the surrogate evaluate() with one that:
- emits the layout (
emit_skill) intoclient.layout.edit(lib, cell), - invokes the PDK's DRC via
client.execute_skill(...)and counts violations, - (optionally) extracts parasitics and runs Spectre for real performance,
and returns the same scalar. The generator, optimizer, and tests here are unchanged.


