Part 1 · Why this file even exists
The one job: take the fuzzy, continuous demands we place on a transistor — "give me at least this much gain, and don't slip out of saturation" — and rewrite them as integer linear inequalities over discrete width and length, so an off-the-shelf constraint solver can hunt for a geometry that satisfies all of them at once.
Here is the whole story with no code at all.
Sizing an analog transistor means choosing two numbers: its width W and length L. But you can't ask for any width — the fabrication process only offers sizes on a fixed grid, like LEGO bricks that come in whole-stud lengths. And the performance you care about (transconductance gm, saturation voltage VDS_sat) depends on W and L through square-root physics — nonlinear, curvy, and division-laden.
A solver like Google OR-Tools' CP-SAT is fantastically good at one narrow thing: searching over integer variables subject to linear constraints. It cannot read a √. It cannot divide one variable by another. So this file is a translator — a little compiler that turns each analog requirement into the one dialect CP-SAT speaks.
Why go to this trouble instead of just calling a numerical optimizer? Because CP-SAT gives you something precious for the card-less generic technology used by level1.py: it simultaneously honors gain targets, headroom limits, matched-pair symmetry, and current-mirror ratios across an entire op-amp, and it either returns a provably valid integer solution or tells you honestly that none exists. It's a sudoku engine for transistor geometry.
The cast of characters
The file is unusually lean: one public builder, one tiny helper, and six constraint-building phases inside the builder. Hover a box to trace where _coeff() is used.
_coeff() feeds phases 2 and 3 (hover it) — every physical coefficient passes through it.We'll learn it bottom-up: first the discrete-grid mindset and the square-law physics (Part 2), then the tiny _coeff() helper (Part 3) and the variables (Part 4). Only then do we reach the centerpiece — the gm linearization (Part 5) — with everything we need to actually feel why squaring is the magic move.
Looking ahead: Part 2 builds the two ideas the whole file rests on — a discrete grid of allowed sizes, and the square-law that ties geometry to performance.
Part 2 · The discrete grid and the square law
Two ideas. Get these and the rest is bookkeeping.
Idea 1 — width and length live on a grid
Analog physics would happily accept W = 2.3847 µm. The fab will not. Layout snaps to a manufacturing grid with a fixed step, so a width is always a whole number of steps:
Think of choosing LEGO bricks: you can pick a 4-stud or a 5-stud brick, never a 4.7-stud one. The integer W_int is "how many studs." This is exactly the shape of variable CP-SAT loves — an integer with a min and a max.
Idea 2 — the square law ties geometry to performance
In the simple long-channel model, a saturated MOSFET's transconductance and its saturation (overdrive) voltage are:
Two things about these to notice now, because they are the entire reason the file exists:
- There's a
√around everything. CP-SAT can't express a square root. - There's a ratio
W/L— one variable divided by another. CP-SAT can't divide two variables either.
So both formulas, as written, are off-limits. The trick in Parts 5 and 6 is to square and cross-multiply the requirement until every √ and every division vanishes, leaving a plain linear inequality. Hold that thought.
Here are the ingredients we'll plug in throughout, chosen to give tidy numbers (and matching the file's own docstring example):
| Symbol | Meaning | Value |
|---|---|---|
µCox | process transconductance parameter | 90 µA/V² |
IDS | the transistor's drain current | 5 µA |
gm_req | required transconductance (a floor) | 60 µA/V |
VDS_sat_max | max allowed overdrive (a ceiling) | ≈ 0.19 V |
width.step, length.step | the fab grid step | 0.1 µm each |
Part 3 · _coeff() and _SCALE — the warm-up
The one job: turn a tiny physical floating-point coefficient (like
0.00000000009) into a small, exact integer CP-SAT can hold — by multiplying by a big constant and rounding.
_SCALE = 10**12
def _coeff(value: float) -> int:
"""Round a physical coefficient to its integer CP-SAT representation."""
return round(value * _SCALE)
Why is this needed? CP-SAT works with integer coefficients. But our physics constants are microscopic — currents in µA (1e-6), µCox in µA/V² (1e-6). Multiply two of them and you get numbers like 9e-11. Feed that to an integer solver and it rounds to zero — the constraint evaporates.
Analogy: a recipe says "0.000009 kg of salt." Useless at that scale. Switch units to micrograms and it becomes "9 µg" — same amount, now a countable whole number. _SCALE = 10¹² is exactly that unit change: it lifts A²/V² products up into a range where they land on tidy integers.
Traced with real numbers
Take the left side of the gm constraint's coefficient, 2 · µCox · IDS · width.step:
2 · (90e-6) · (5e-6) · 0.1 = 9e-11 # microscopic float
_coeff(9e-11) = round(9e-11 · 1e12) = round(90) = 90 # tidy integer ✓
The last point is the quiet cleverness: because every coefficient in a constraint is multiplied by the same _SCALE, the inequality 90·W ≥ 360·L holds exactly when the original physical one does. Scaling both sides of a ≥ b by a positive number can't flip it.
Why I like the name.
_coeffis short for "coefficient" — the number multiplying a variable in the linear model. That's precisely what it returns. The leading underscore whispers "internal helper, don't call me from outside." Good, honest naming.
Part 4 · The W and L variables
The one job: for every transistor, create one integer variable for width and one for length — each counting grid steps, each clamped to the fab's legal min/max.
w_step = tech.width.step
l_step = tech.length.step
w_min_int = round(tech.width.min / w_step)
w_max_int = round(tech.width.max / w_step)
l_min_int = round(tech.length.min / l_step)
l_max_int = round(tech.length.max / l_step)
W: dict[str, cp_model.IntVar] = {}
L: dict[str, cp_model.IntVar] = {}
for ref in transistors:
W[ref] = model.new_int_var(w_min_int, w_max_int, f"W_{ref}")
L[ref] = model.new_int_var(l_min_int, l_max_int, f"L_{ref}")
The single most important lines are the two new_int_var calls. Everything else in the file exists to constrain these. Each is an integer variable — a dial the solver may turn — but only between min_int and max_int stops.
Converting µm to grid steps is just division by the step. Say the fab allows widths from 0.5 µm to 10 µm on a 0.1 µm grid:
w_min_int = round(0.5 / 0.1) = 5 # 5 steps = 0.5 µm
w_max_int = round(10 / 0.1) = 100 # 100 steps = 10 µm
Notice the deliberate split of responsibilities: the variables carry the counts, the coefficients carry the physics. That separation is what lets a single linear inequality mean something physical while staying pure integers — and it's why each constraint's w_step/l_step gets folded into its coefficient (we saw 90 being born that way in Part 3).
Part 5 · The gm lower bound — the centerpiece
The one job: guarantee each transistor delivers at least its required transconductance — by rewriting the curvy requirement
gm ≥ gm_reqas a single straight-line inequality in the integer variablesWandL.
This is the heart of the file, and it's where the √ finally dies. Let's do the algebra one honest step at a time, then trace it with numbers, then see it as geometry.
The linearization, step by step
We start from the requirement and substitute the square law from Part 2:
substitute the square law →
both sides positive, so square them (kills the √) →
multiply through by L (kills the division) →
Look at what happened. The left side is a constant times W. The right side is a constant times L. No √, no W/L — it is linear in the two integer variables. This is the whole trick of the file, and it is genuinely small once you see it.
Where does the sqrt actually "live"?
It helps to see the nonlinearity before we flatten it. Hold L fixed and sweep W: because gm ∝ √W, transconductance rises along a curve that flattens out. The requirement gm ≥ gm_req is a horizontal line. Feasibility is every W to the right of where the curve crosses it.
W = 4·L for our numbers.Plug in the running numbers
With µCox = 90 µA/V², IDS = 5 µA, gm_req = 60 µA/V (so gm_req² = 3.6×10⁻⁹), and both grid steps 0.1 µm, the code computes the two integer coefficients:
lhs = _coeff(2.0 · µCox · |IDS| · w_step)
= _coeff(2 · 90e-6 · 5e-6 · 0.1) = _coeff(9e-11) = 90 # coeff of W
rhs = _coeff(gm_req**2 · l_step)
= _coeff(3.6e-9 · 0.1) = _coeff(3.6e-10) = 360 # coeff of L
And the constraint handed to the solver is simply:
Here is the exact code that emits it — note the guard that skips transistors with no gm requirement or no current:
for ref, (device, _slot) in transistors.items():
gm_req = gm_req_map.get(ref, 0.0)
if gm_req <= 0.0: continue # unconstrained → skip
ids_a = ids_map.get(ref, 0.0)
if ids_a == 0.0: continue
params = tech.nmos if device.type == "nmos" else tech.pmos
lhs = _coeff(2.0 * params.mu_cox * abs(ids_a) * w_step)
rhs = _coeff(gm_req ** 2 * l_step)
if lhs > 0 and rhs > 0:
model.add(lhs * W[ref] >= rhs * L[ref])
See it as geometry: a linear half-plane over the lattice
Now the payoff. Plot every legal (L_int, W_int) as a dot on a lattice. The constraint W ≥ 4·L is a straight ray from the origin; every dot on or above it satisfies the gain requirement. The solver's job becomes: pick a lattice dot inside the shaded wedge.
A quick sanity check by hand. Point (L=2, W=6): is 90·6 ≥ 360·2? That's 540 ≥ 720 — false, so it's red. Point (L=2, W=8): 90·8 = 720 ≥ 720 — true, sitting right on the boundary, green. The picture and the arithmetic agree.
Why "lower bound"?
gm_reqis a floor — the amplifier needs at least this transconductance. More gm is never a spec violation. So the constraint is a≥, carving out a half-plane of "wide enough" geometries, and a slightly-too-strong device is perfectly fine.
Part 6 · The VDS_sat upper bound — same trick, mirrored
The one job: keep each transistor's overdrive voltage under a ceiling (so it has room to stay saturated) — again by squaring and cross-multiplying the requirement into a straight line in
WandL.
Once you've seen the gm move, this one is déjà vu. Start from the overdrive requirement and the square law:
square both sides, then multiply through by µCox·W →
Linear again. This time L carries the left coefficient and W the right. The code mirrors Part 5 almost line for line:
for ref, (device, _slot) in transistors.items():
vod_max = vod_max_map.get(ref, float("inf"))
if vod_max == float("inf") or vod_max <= 0.0: continue
ids_a = ids_map.get(ref, 0.0)
if ids_a == 0.0: continue
params = tech.nmos if device.type == "nmos" else tech.pmos
lhs = _coeff(2.0 * abs(ids_a) * l_step) # coeff of L
rhs = _coeff(params.mu_cox * vod_max ** 2 * w_step) # coeff of W
if lhs > 0 and rhs > 0:
model.add(lhs * L[ref] <= rhs * W[ref])
Two guards worth reading like English. An entry equal to float("inf") means "no overdrive ceiling on this device" — skip it. And a device with ids_a == 0 carries no current, so there's nothing to constrain.
Plug in numbers, and stack it on the gm line
With IDS = 5 µA, VDS_sat_max ≈ 0.19 V (so Vov² ≈ 0.037), the coefficients come out large but exact — a good reminder of what _SCALE buys us:
lhs = _coeff(2 · 5e-6 · 0.1) = _coeff(1e-6) = 1_000_000
rhs = _coeff(90e-6 · 0.037 · 0.1) = _coeff(3.33e-7) = 333_000
constraint: 1_000_000 · L ≤ 333_000 · W ⟺ W ≥ 3 · L
So this transistor now faces two half-planes at once: gm wants W ≥ 4·L, overdrive wants W ≥ 3·L. Both push W up; the tighter one (the steeper ray) wins. Here that's the gm constraint.
Point (L=2, W=7) makes the lesson concrete: 7 ≥ 3·2 = 6 ✓ overdrive, but 7 < 4·2 = 8 ✗ gain. It sits between the two rays — legal for VDS_sat, rejected by gm. Only (2,8) and above satisfy both.
Part 7 · Matching — symmetry and current-mirror ratios
The one job: tie related transistors together so the circuit is physically self-consistent — identical devices get identical sizes, and a mirror's output width scales with its current ratio.
The gm and VDS_sat constraints size each transistor in isolation. But real op-amps demand that certain devices match. Two more families of constraints enforce that — and both are already linear, so they drop straight into CP-SAT with no algebra needed.
Symmetry — matched pairs must be twins
Inside designated slots (input_pair, load, tail_current), same-type devices carrying the same current must be the same size. An imbalanced input pair injects offset; mismatched loads skew the mirror. The code groups by (type, IDS) and pins every member equal to the first:
for group in groups.values():
for i in range(1, len(group)):
model.add(W[group[i].ref] == W[group[0].ref])
model.add(L[group[i].ref] == L[group[0].ref])
Grouping by current too, not just type, is a subtle correctness point the comment calls out: a folded-cascode load's folding sinks and its cascode devices are the same type but carry different currents — forcing them equal would fight the mirror-ratio constraints below. A cross-slot variant does the same for the p↔n halves of fully-differential gain stages, anchoring everything to one device.
Current-mirror ratios — the photocopier
A current mirror is a copier: a diode-connected reference transistor sets a current, and each output transistor reproduces a scaled copy. The physics is I_out / I_ref = (W/L)_out / (W/L)_ref. If nothing pins this, an output's W/L is set only by its own gm/VDS_sat target and the mirror sources some arbitrary current. So the code fixes it: match the length, and scale the width by the exact current ratio.
frac = Fraction(i_m / i_ref).limit_denominator(100)
model.add(L[m] == L[ref0])
model.add(frac.denominator * W[m] == frac.numerator * W[ref0])
Traced with numbers: reference carries I_ref = 5 µA, an output wants I_out = 10 µA. Then I_out/I_ref = 2, which Fraction(...).limit_denominator(100) renders as 2/1. So L is shared and 1·W_out = 2·W_ref — the output is twice as wide, doubling the current. Exactly what a 2× mirror should do.
1·W_out = 2·W_ref is a plain linear equation — CP-SAT ingests it as-is.Why limit_denominator(100)? A raw float ratio like 1.9999998 would create a monstrous exact fraction. Capping the denominator at 100 rounds it to the nearest clean ratio (2/1), which is both what the designer meant and what keeps the integer coefficients small. It's an honest approximation — more on that next.
Part 8 · Objective, branching, and the hidden idea
The one job: among all geometries that satisfy every constraint, tell the solver which to prefer, and give it a smart order to search — then hand back the finished model.
The objective — smallest total width
model.minimize(sum(W.values()))
Constraints define what's allowed; the objective picks the best among them. Minimizing total gate width is a cheap, effective proxy for both area and power — narrower devices draw less and take less silicon. The constraints already guarantee "wide enough for gain and headroom," so the objective just refuses to go one grid step wider than necessary.
The branching heuristic — bias network first
bias_refs = [ref for ref, (_d, slot) in transistors.items() if slot == "bias_gen"]
...
model.add_decision_strategy(priority_vars, cp_model.CHOOSE_FIRST, cp_model.SELECT_MIN_VALUE)
This doesn't change what is feasible — only how fast the solver gets there. The bias generator sets currents that everything else mirrors, so deciding those variables first (and trying their smallest legal value first) prunes the search dramatically. It's telling the sudoku solver "fill in the cells that constrain the most others before guessing elsewhere."
The hidden idea, named out loud
Though it's never written as a headline, this whole file is one act of translation. Every phase converts a slice of continuous analog theory into the canonical form of an integer linear program:
Read it aloud: "find the integer widths and lengths that minimize total width, subject to a stack of linear inequalities." The √-and-ratio physics of gm and VDS_sat became rows of that linear system A·(W,L) ≥ b; symmetry and mirror ratios became equality rows. Squaring is the single move that made those rows linear — and being linear-over-integers is precisely the definition of a problem CP-SAT can solve.
What it honestly does not do
- Grid granularity. The true optimum might sit at
W = 6.4steps; the solver can only land on6or7. Finer grids cost search time. - Square-law only. The gm and VDS_sat formulas are the idealized long-channel model — no velocity saturation, no short-channel effects, no
λin these particular rows. It's a first cut, meant for the card-lessgenerictechnology, to be refined by a downstream SPICE check. - Rational mirror ratios.
limit_denominator(100)means a desired ratio of1.9999998is quietly rounded to2/1. Ratios needing denominators above 100 aren't represented exactly. - Guarded skips. Any device with no gm requirement, no overdrive ceiling, or zero current simply gets no constraint from that phase — the model constrains only what it's told to.
Why it's elegant
The whole file rides one insight: a nonlinear analog requirement, once squared and cross-multiplied, becomes a linear inequality between integer grid-step counts — and a pile of such inequalities is exactly a constraint-satisfaction problem a general solver can crack, complete with symmetry and mirror ratios handled uniformly. Continuous circuit theory in; a discrete, provable geometry out.
In one sentence: constraints.py compiles each op-amp performance target into integer-linear form — squaring away the √ and cross-multiplying away the W/L ratio so gm ≥ gm_req becomes 2·µCox·IDS·W ≥ gm_req²·L (and VDS_sat likewise) — then adds symmetry and current-mirror equalities and a minimize-total-width objective, handing CP-SAT a clean discrete search over transistor geometry.