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/Idpipeline 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.
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:
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:
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.
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.
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:
| Call | What it produces | Why it exists |
|---|---|---|
extract_slot_transistors | devices grouped by role (input_pair, tail, load…) | the solver reasons about roles, not raw refs |
deduplicate_devices | one entry per distinct device to size | matched twins share one geometry, not two |
assign_ids | ids_map: ref → drain current | current is fixed by Kirchhoff before sizing |
size_load_resistors | resistor values + a load conductance gd_load_r | resistor loads are deterministic; solve them directly |
compute_requirements | gm_req_map, vod_max_map, comp caps | translate 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:
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:
| Quantity | Value |
|---|---|
µCox | 100 µA/V² |
IDS (from ids_map) | 10 µA |
gmreq (from gm_req_map) | 0.2 mA/V |
| width grid step | 0.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.
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.
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.
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 aFEASIBLE(not proven optimal) answer, or returnINFEASIBLEand 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.