level1.py — explained

circuitgenome/sizer/analytical/level1.py — the Level-1 square-law sizing pipeline for the card-less generic technology.

Part 1 · Why this file even exists

The one job: given a recognized circuit and a spec, choose an actual width and length for every transistor — using the simple square-law model and a discrete parts catalog — so the amplifier meets its requirements.

Here is the whole story before a single line of code.

A sizer's job is to turn a topology (which transistors connect to what) plus a spec (gain, bandwidth, power…) into concrete geometries: numbers like "this device is 3.0 µm wide and 0.15 µm long." CircuitGenome has two ways to do this. The fancy one reads a measured gm/Id lookup table baked into a technology card. But some technologies are card-less — the generic, no-PDK case — and have no table to read.

That's what level1.py is for. It sizes using the textbook square-law (Level-1) MOSFET model, where a transistor's behaviour follows a clean algebraic formula instead of a measured table. And because the generic technology only offers geometries on a fixed manufacturing grid, it can't spin a smooth dial — it must pick parts from a discrete catalog. It hands that discrete search to a real constraint solver, Google OR-Tools' CP-SAT.

Think of the file as a factory production line. Most of its stations are shared with the fancy sizer — cutting the raw netlist into slots, de-duplicating matched devices, assigning currents, computing what each device must achieve, and finally scoring the finished amplifier. Only one station is special to Level-1: the machine that actually picks each transistor's width and length off the catalog. Everything else is borrowed.

The punchline, stated early: the only thing that separates this pipeline from the gm/Id pipeline is the geometry-search station in the middle. The prep before it and the scoring after it are literally the same shared code.

The cast of characters

One public function, size_level1(), orchestrates five collaborators. Hover any box to trace it.

size_level1() shared.preprocess extract · dedup · requirements Level1Model square-law device physics build_model() .constraints · discrete W/L CpSolver.solve() OR-Tools CP-SAT _evaluate_metrics() shared.metrics
One orchestrator, five helpers. Four are shared with the gm/Id sizer; only the teal box — build_model() — is special to Level-1.

Looking ahead: Part 2 builds the two ideas the whole file rests on — the square-law formula, and why a discrete catalog forces a solver instead of a dial.

Part 2 · Two ideas: the square law and a parts catalog

Two concepts unlock everything. Neither is scary.

Idea 1 — the square-law model

In the textbook (Level-1) model, a saturated MOSFET's transconductance gm — how strongly it converts voltage into current — follows one clean formula:

gm= 2·µCox ·WL ·IDS

Read it in words: a transistor's strength grows with the square root of its shape W/L and its current IDS. The term µCox is a fixed property of the technology. That is the entire physics of the Level1Model — a few algebraic formulas, no measured table required. Its saturation-voltage cousin is just as clean:

Vdsat= 2·IDS µCox·WL

Idea 2 — a discrete catalog, not a smooth dial

The fancy gm/Id sizer reads a measured table and can, in effect, dial any operating point it likes. The generic technology can't: it only manufactures widths and lengths on a fixed grid — like a hardware store that stocks lumber in whole tenths of a millimetre, nothing in between. So sizing here isn't "turn a knob to the value you want." It's "pick the best parts from a catalog of allowed sizes." Picking discrete parts to satisfy constraints is exactly what a constraint solver is built for — hence CP-SAT.

gm/Id path: a smooth dial stop anywhere — read the table Level-1: a discrete catalog only grid steps — a solver picks one
Left: a lookup table lets the gm/Id sizer stop anywhere. Right: Level-1 must choose from fixed grid steps — a discrete search.

How the stations line up

Zoomed out, size_level1() is a five-stage assembly line. Watch where the two paths agree and where they split.

preprocess shared build model CP-SAT solve CP-SAT assemble steps → µm evaluate shared metrics
Five stages, output feeding input. The two teal stages in the middle are the Level-1 geometry search; the rest is shared code.

Looking ahead: Part 3 walks the shared prep stations that run before the solver — turning a raw netlist into a tidy list of requirements.

Part 3 · The shared prep stations

The one job: turn the recognizer's messy output into three clean inputs the solver needs — which devices to size, how much current each carries, and what target each must hit.

None of this code is Level-1-specific. It is imported wholesale from sizer.shared.preprocess, the same module the gm/Id sizer uses. Here is the whole prep block.

slot_transistors  = extract_slot_transistors(fbr_result)
topology_warnings = check_topology_match(slot_transistors, topology.name)
all_transistors   = deduplicate_devices(slot_transistors)
ids_map           = assign_ids(slot_transistors, all_transistors, spec)

# Size resistor loads (deterministic) and fold them into first-stage Rout.
resistors = size_load_resistors(extract_slot_resistors(fbr_result), spec, tech)
gd_load_r = (1.0 / min(resistors.values())) if resistors else 0.0

dev_model = Level1Model(tech)
gm_req_map, vod_max_map, cc_pf, cc2_pf, gm_ceiling_warnings = compute_requirements(
    slot_transistors, all_transistors, ids_map, tech, spec, dev_model, gd_load_r
)
all_warnings = topology_warnings + gm_ceiling_warnings

Read it as five short moves, each a station on the line:

CallWhat it producesWhy it exists
extract_slot_transistorsdevices grouped by role (input_pair, tail, load…)the solver reasons about roles, not raw refs
deduplicate_devicesone entry per distinct device to sizematched twins share one geometry, not two
assign_idsids_map: ref → drain currentcurrent is fixed by Kirchhoff before sizing
size_load_resistorsresistor values + a load conductance gd_load_rresistor loads are deterministic; solve them directly
compute_requirementsgm_req_map, vod_max_map, comp capstranslate the spec into a per-device target

The two maps at the end are the real hand-off. gm_req_map says "device X must reach at least this transconductance," and vod_max_map says "device X may spend at most this saturation voltage." Everything the solver does is chasing those two numbers per device.

Why Level1Model(tech) matters here

Notice compute_requirements is handed a dev_model. This is the one spot in prep where the square-law choice leaks in: the requirements are computed through the Level-1 formulas from Part 2. Swap in the gm/Id model and the same function computes the same-shaped maps a different way. That is precisely why the surrounding code can be shared — the model is a plug-in.

Looking ahead: now we have per-device targets. Part 4 is the heart — turning those targets into a discrete W/L pick with CP-SAT.

Part 4 · The geometry search — the only new part

The one job: hand the per-device targets to build_model(), which encodes them as integer constraints over grid-step W and L, then let CP-SAT pick the smallest-width sizing that satisfies them all.

This is the single station that makes level1.py different from every other sizer in the codebase. It is only five lines.

cp_mdl, W_vars, L_vars = build_model(
    all_transistors, slot_transistors, ids_map, gm_req_map, vod_max_map, tech
)
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = time_limit_s
status = solver.solve(cp_mdl)
status_name = solver.status_name(status)

build_model() lives in the sibling constraints.py (its own page: constraints_explained.html). We only need its shape here: it invents two integer variables per device — W_vars[ref] and L_vars[ref], both counted in grid steps, not µm — and writes down the targets as linear inequalities.

The hidden trick: a square root becomes linear

Here is the beautiful part. The gm ≥ gm_req requirement looks non-linear — there's a square root and a division. But square both sides and separate W from L, and it collapses into a plain linear inequality a CP-SAT solver eats for breakfast:

√( 2·µCox·(W/L)·IDS ) ≥ gmreq ⟶ square & cross-multiply ⟶
2·µCox·IDS·W ≥ gmreq2·L

Now W and L appear separately and only to the first power — linear. The Vdsat ≤ vod_max cap linearises the same way. build_model() also adds matched-pair symmetry (twins get equal W and L) and current-mirror ratio constraints, then sets the objective: minimise total width, a proxy for power and area.

A requirement, traced to a discrete pick

Let's follow one device with real numbers. Suppose:

QuantityValue
µCox100 µA/V²
IDS (from ids_map)10 µA
gmreq (from gm_req_map)0.2 mA/V
width grid step0.1 µm
length (at grid min)0.15 µm

Plug into the linearised gm constraint 2·µCox·IDS·W ≥ gmreq²·L. Dividing through, it says simply W/L ≥ gmreq² / (2·µCox·IDS):

W/L  ≥  (2e-4)² / (2 · 100e-6 · 10e-6)
     =  4e-8 / 2e-9
     =  20

So this device needs a shape ratio of at least 20. With L pinned at its grid minimum 0.15 µm, that means W ≥ 20 × 0.15 = 3.0 µm. The solver now scans the discrete width catalog and, because it is minimising width, grabs the smallest grid step that still clears 3.0 µm.

W candidates at L = 0.15 µm (grid step 0.1 µm) → W = 2.6W/L 17.3 ✗ W = 2.8W/L 18.7 ✗ W = 3.0W/L 20.0 ✓ W = 3.2W/L 21.3 W = 3.4W/L 22.7 minimise width → first W with W/L ≥ 20 wins → 3.0 µm
The solver walks the grid and stops at the narrowest width that still meets the ratio — the discrete catalog pick, W = 3.0 µm, L = 0.15 µm.

Let's confirm the pick actually meets the target. With W/L = 20:

gm = √(2 · 100e-6 · 20 · 10e-6) = √(4e-8) = 2e-4 = 0.2 mA/V  ✓ exactly gm_req

And its saturation voltage stays under the cap: Vdsat = √(2·10e-6 / (100e-6·20)) = √0.01 = 100 mV. The solver did in one shot what a human would do by hand — but across every device at once, respecting every matched-pair and mirror constraint simultaneously.

The time_limit_s (default 30 s) is the safety valve: CP-SAT keeps improving until it's optimal or the clock runs out, then reports the best it found. Its verdict lands in status.

Looking ahead: the solver returns a status. Part 5 reads it, and either assembles the answer or bails out honestly.

Part 5 · Solve, then assemble or bail

The one job: if the solver found a sizing, convert its integer step-counts back into micrometres, score the amplifier, and package a SizingResult; if it didn't, return an honest empty result carrying the failure status.

The fork

Everything downstream hinges on one branch.

solver status? OPTIMAL / FEASIBLE? no yes give up honestly transistors = {} solver_status kept assemble result steps → µm, build sizing then evaluate metrics
The whole tail of the function is one if/else on the solver's verdict — a clean happy path and an honest fallback.
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    return SizingResult(
        transistors={}, cc_pf=cc_pf, metrics={}, margins={},
        solver_status=status_name, cc2_pf=cc2_pf,
        warnings=all_warnings, resistors=resistors,
    )

The fallback is careful: it still returns the resistor values, the compensation caps, and the warnings it already gathered — it just hands back an empty transistor map and the status name (say "INFEASIBLE"). Callers learn exactly why, and nothing pretends to be a valid sizing.

The happy path — integers back to micrometres

Remember the solver worked in grid steps. To make a real answer we multiply each step-count by its step size.

w_step = tech.width.step
l_step = tech.length.step
transistor_sizing = {}
for ref, (device, _slot) in all_transistors.items():
    w_um = solver.value(W_vars[ref]) * w_step
    l_um = solver.value(L_vars[ref]) * l_step
    ids_a = ids_map[ref]
    transistor_sizing[ref] = TransistorSizing(
        ref=ref, w_um=w_um, l_um=l_um, ids_a=ids_a,
        vgs_v=dev_model.vgs(device.type, w_um, l_um, ids_a),
        vds_sat_v=dev_model.vds_sat(device.type, w_um, l_um, ids_a),
    )

Trace our device: the solver returned W_vars[ref] = 30 steps and L_vars[ref] = 3 steps. Multiply back:

w_um = 30 · 0.1  = 3.0 µm
l_um = 3  · 0.05 = 0.15 µm

Then it re-derives vgs and vds_sat through the same Level1Model and stores a tidy TransistorSizing record per device. The integer world stays inside the solver; the outside world only ever sees micrometres and volts.

Score, then package

metrics, margins = _evaluate_metrics(
    transistor_sizing, slot_transistors, cc_pf, tech, spec, dev_model,
    cc2_pf=cc2_pf, gd_load_r=gd_load_r,
)
return SizingResult(
    transistors=transistor_sizing, cc_pf=cc_pf, metrics=metrics,
    margins=margins, solver_status=status_name, cc2_pf=cc2_pf,
    warnings=all_warnings, resistors=resistors,
)

_evaluate_metrics is — once again — shared code. It takes the finished geometries and computes the amplifier's actual gain, bandwidth, and their margins against spec. The function then returns one SizingResult bundling geometries, metrics, margins, warnings, resistors, and the solver status. That object is the file's product.

Part 6 · The hidden idea, the limits, the summary

The hidden idea, made visible

Step back and the design intent is unmistakable. Two sizing pipelines run through the same shared stations on both ends, and diverge in exactly one box in the middle.

gm/Id path Level-1 path shared preprocess gm/Id LUT table lookup shared metrics shared preprocess CP-SAT search discrete W/L shared metrics only this box changes
Same prep, same scoring. The whole identity of Level-1 sizing is a single swapped station — a discrete CP-SAT search in place of a table lookup.

That is the elegance worth naming: the file achieves a whole alternative sizing strategy by reusing everything except the one part that must differ. The square-law model plugs in as dev_model; the discrete search plugs in as build_model; the rest is untouched shared code.

What it honestly does not do

  • Discrete grid only. The answer is the best point on the manufacturing grid — a value between two grid steps is unreachable by construction.
  • Square-law validity. The Level-1 model is an idealization. Short-channel effects, mobility degradation, and true weak inversion are not captured, so the metrics are optimistic estimates, not SPICE truth.
  • Solver time and infeasibility. CP-SAT is bounded by time_limit_s; a hard problem can time out with only a FEASIBLE (not proven optimal) answer, or return INFEASIBLE and force the honest empty result from Part 5.
  • Resistors are pre-solved. Resistor loads are sized deterministically and folded in as a conductance — they are not part of the CP-SAT search.

In one sentence: level1.py sizes a card-less generic-technology amplifier by running the same shared preprocessing and metric evaluation as the gm/Id sizer, but replacing the one middle station with a square-law-driven CP-SAT search that picks each transistor's discrete grid-step width and length to meet its per-device gm and Vdsat targets at minimum total width.