Part 1 · Why this file even exists
The one job: give the op-amp sizer one way to ask a transistor "what's your gm? your gds? your Vdsat?" — so the same topology math works whether those answers come from a textbook square law or from a measured lookup table.
Here is the whole idea before a single line of code.
An op-amp designer computes performance from a handful of relationships that are the same for every technology:
Gain, gain-bandwidth, phase margin, CMRR — all of them are built from just gm, gds, Vdsat, Vgs. The formulas never change. Only the device primitives — the actual numbers that come back when you ask "what is this transistor's gm?" — differ between technologies.
CircuitGenome has two ways to produce those primitives:
- the Level-1 square law (the Shichman-Hodges equations in
equations.py), for a card-less generic technology, and - a gm/Id lookup table (
GmIdLut), pre-extracted from real BSIM4 simulations, for PTM process nodes.
The analogy: a universal wall socket
Think of a standardized wall socket. Your laptop charger and your lamp both end in the same plug shape. The wall doesn't know or care which appliance you push in — it exposes one contract (two flat pins + ground), and any compliant plug works.
DeviceModel is that socket. It defines six pins — gm, gds, vds_sat, vgs, gm_ceiling, gds_estimate — and declares nothing about how they're computed. Then two plugs are built to fit it: Level1Model (square law) and GmIdModel (LUT). The sizer only ever talks to the socket.
This is the Strategy pattern: a fixed interface, swappable implementations chosen at runtime. Without it, every gain / GBW / CMRR formula in the sizer would need an if gmid: … else: … fork. With it, they write model.gm(...) once and forget which technology they're in.
The cast of characters
The file holds two model classes, two little data holders, and one selector. Hover any box to trace who it calls.
Looking ahead: Part 2 opens the socket and names its six pins — what each primitive actually means in a circuit — before we watch either backend fill them in.
Part 2 · The five primitives (what the socket promises)
Before any implementation, you only need to understand the questions the interface can answer. Every one takes a device (dtype = nmos/pmos), its geometry (W, L in µm), and its current (Id in A), and returns one small-signal number.
| Primitive | Plain-English question | Units |
|---|---|---|
gm | How strongly does this device turn drain current into a signal? (transconductance) | A/V |
gds | How "leaky" is it — how far from an ideal current source? (output conductance) | A/V |
vds_sat | How much drain-source voltage does it need to stay saturated? | V |
vgs | How much gate-source voltage does it "cost" to turn on? (signed) | V |
gm_ceiling | What is the hard physical maximum gm at this current? (weak-inversion limit) | A/V |
gds_estimate | A geometry-free gds guess from the device's role, before W/L exist. | A/V |
Why does gm matter so much? Because it is the numerator of nearly every op-amp figure: gain scales with gm·rout, and gain-bandwidth is gm₁/2πCc. Give the sizer accurate primitives and the topology math is correct; give it wrong ones and every downstream metric is wrong too.
The crucial insight is that one call, two backends, two answers — but the caller writes the same line either way:
Notice gds_estimate is the odd one out — it takes a role, not geometry. It exists for a chicken-and-egg moment during requirement derivation: the sizer needs a rough gds to size for gain before it has chosen any W/L. So the interface offers a geometry-free estimate keyed on what the device is for. Hold that thought — it pays off in Part 6.
Looking ahead: Part 3 reads the actual
Protocoldeclaration and the tiny_params()helper that both backends lean on.
Part 3 · The Protocol & _params()
The one job: write down the socket's shape as a Python
Protocol, and give both backends one shared line for "fetch the nmos-or-pmos parameter block."
A Protocol is Python's version of a socket spec sheet. It lists method signatures with no bodies — anything that has these methods "fits," with no inheritance required (structural typing, aka duck typing made explicit).
class DeviceModel(Protocol):
is_gmid: bool
def gm(self, dtype, w_um, l_um, ids) -> float: ...
def gds(self, dtype, w_um, l_um, ids) -> float: ...
def vds_sat(self, dtype, w_um, l_um, ids) -> float: ...
def vgs(self, dtype, w_um, l_um, ids) -> float: ...
def gm_ceiling(self, dtype, ids, l_um) -> float: ...
def gds_estimate(self, dtype, ids, role) -> float: ...
The ... bodies are literally the word "ellipsis" — the spec says what, never how. The lone data field is_gmid is a runtime flag callers use for the rare case where they genuinely must know which path they're on (like the bias-repair pass, which only makes sense for the LUT backend).
Why is it called _params()? Because it just picks a drawer
def _params(tech, dtype) -> MosfetParams:
return tech.nmos if dtype == "nmos" else tech.pmos
A technology carries two parameter blocks — one for NMOS, one for PMOS. Like a filing cabinet with two labeled drawers. Every primitive starts by opening the right drawer: _params(self.tech, "pmos") hands back the PMOS µCox, Vth, λ. It is a one-line utility, but it is the seam that keeps every method polarity-correct without repeating the ternary five times.
Looking ahead: Part 4 fills the socket with its simplest plug —
Level1Model, which is a thin, regression-safe wrapper over the textbook square-law equations.
Part 4 · Level1Model — the square-law backend
The one job: answer every primitive from the Shichman-Hodges square law, reproducing the existing generic-tech numbers byte-for-byte.
This is the plug you build first because it is the simplest: each method is a one-line delegation to equations.py. The class exists so those loose functions present the DeviceModel face.
class Level1Model:
is_gmid = False
def gm(self, dtype, w_um, l_um, ids):
return eq.gm(_params(self.tech, dtype).mu_cox, w_um, l_um, ids)
def gds(self, dtype, w_um, l_um, ids):
return eq.gd(_params(self.tech, dtype).lam, ids)
The star primitive is gm. The square law says transconductance grows with the square root of both the device's shape and its current:
Traced with real numbers
Take an NMOS with µCox = 200 µA/V², W = 10 µm, L = 1 µm, Id = 100 µA:
gm = √(2 · 200e-6 · (10/1) · 100e-6)
= √(4e-7)
= 6.32e-4 A/V = 632 µS
Two tells that this is the "cheap" model
Look at gds: it returns eq.gd(λ, Id) = λ·|Id| and ignores W and L entirely. Under Level-1, output conductance is purely current-proportional — geometry doesn't enter. That's a known limitation of the square law, faithfully preserved.
And gm_ceiling returns eq.gm_ceiling(ids) = 25·|Id|, independent of dtype and L. It's the weak-inversion limit baked in as a flat heuristic. Finally, gds_estimate just returns λ·|Id| again — the docstring says it plainly: "role is irrelevant under the square law." The Level-1 world has no role-dependence because its primitives don't depend on operating point.
Looking ahead: Part 5 swaps in the richer plug —
GmIdModel— where every primitive is a measured lookup and the geometry finally matters.
Part 5 · GmIdModel — LUT-backed primitives
The one job: answer the same six questions, but from a table of real BSIM4 behaviour indexed by the device's operating point, not a textbook formula.
The gm/Id method sizes a transistor by its transconductance efficiency gm/Id — a single knob that says how deep into weak or strong inversion the device sits. The lookup table GmIdLut stores, for each (gm/Id, L) point, the measured id/W, intrinsic gain gm/gds, Vdsat, and Vgs.
The single most important method: _gm_id_at()
Every primitive here first needs to know where on the table a solved device sits. Geometry gives us (W, L, Id); the table is indexed by gm/Id. So we invert:
def _gm_id_at(self, dtype, w_um, l_um, ids):
if w_um <= 0:
return self.policy.cs_gmid # degenerate guard
return self.lut.gm_id_from_idw(dtype, abs(ids) / w_um, l_um)
The trick: current density Id/W pins the operating point. A device pushing more current per micron of width is deeper in strong inversion (lower gm/Id). So Id/W plus L is enough to recover gm/Id from the table.
Now the primitives fall out in one line each
Once you have the operating gm/Id, the answers are direct:
def gm(self, dtype, w_um, l_um, ids):
return self._gm_id_at(dtype, w_um, l_um, ids) * abs(ids)
def gds(self, dtype, w_um, l_um, ids):
gm_id = self._gm_id_at(dtype, w_um, l_um, ids)
gm = gm_id * abs(ids)
return gm / self.lut.gm_gds(dtype, gm_id, l_um)
gm is just the identity gm = (gm/Id)·Id. And gds = gm / (gm/gds) — the table hands back the intrinsic gain gm/gds, so we divide. Compare to Level-1: here gds genuinely depends on geometry, because gm/gds is read at the device's real operating point and length.
Traced with real numbers
gm/Id (from table) = 15 /V at Id/W = 10 µA/µm, L = 1 µm
gm = 15 · 100µA = 1.50 mA/V
gm/gds (from table) = 20 intrinsic gain
gds = 1.50m / 20 = 75 µS
Vdsat (from table) = 0.12 V
Vgs (from table) = +0.45 V (NMOS → positive)
The vgs method adds one careful touch: the table stores a magnitude, so the code re-applies the sign from the threshold — mag if vth >= 0 else -mag — giving + for NMOS, − for PMOS. That sign convention is exactly what the Level-1 vgs_from_ids also produces, so the socket's contract holds across both plugs.
Looking ahead: the gm/Id plug has two extra powers Level-1 lacks — a policy for choosing L by role (Part 6) and full geometry inversion (Part 7). Those are what let it not just measure devices but size them.
Part 6 · GmIdPolicy & role-based length
The one job: decide a device's channel length and nominal gm/Id from what it is for — a signal device, a current source, or a cascode — via a small table of tunable knobs.
Remember gds_estimate from Part 2 — the geometry-free question. To answer it, the model needs a sensible default operating point before W/L exist. That default comes from the device's role. Two (well, three) roles drive everything:
| Role | What it is | L multiple | gm/Id |
|---|---|---|---|
"signal" | input pair, gain-stage driver | 2× | 14 (nominal) |
"cascode" | cascode device (small Vdsat) | 3× | 8 |
"current_source" | tail, active load, bias gen | 4× | 10 |
The intuition is physical. A signal device wants a balance of gain and speed → moderate L. A current source wants high output resistance → long L and low gm/Id (strong inversion, for headroom). A cascode wants a small Vdsat → strong inversion but a middling L. These live in the GmIdPolicy dataclass as defaults tuned during SPICE validation.
The plumbing is three tiny methods. role_length() looks up the multiplier for a role and calls length_for(), which multiplies by L_min and snaps to the grid via _snap_l(). And _role_gm_id() returns the cascode or current-source nominal gm/Id.
def role_length(self, role):
mult = {CURRENT_SOURCE: self.policy.cs_l_mult,
CASCODE: self.policy.cascode_l_mult}.get(role, self.policy.signal_l_mult)
return self.length_for(mult)
Note the .get(role, signal_l_mult) default — anything that isn't explicitly a current source or cascode is treated as signal. That's the "unknown → signal" fallback.
Now gds_estimate finally makes sense: pick the role's L, pick the role's nominal gm/Id (signal uses signal_nominal_gmid = 14), compute gm = (gm/Id)·Id, then gds = gm / (gm/gds) from the table. A gds guess with no geometry — exactly what requirement-derivation needs before it commits to a W and L.
Looking ahead: Part 7 is the gm/Id backend's crown jewel —
geometry_for(), which runs the whole thing backwards: from a target gm to an actual (W, L).
Part 7 · geometry_for() — inverting to (W, L)
The one job: given a device's role, its current, and (for signal devices) a target gm, choose the actual channel length and width that hit it — clamped to what the table physically allows.
Every primitive so far went forward: geometry in, small-signal number out. The procedural sizer needs the inverse: "I want this much gm at this current — what W and L do I build?" That's geometry_for().
The logic is a clean fork on the device's role:
l_um = l_um if l_um is not None else self.role_length(role)
if role != SIGNAL or not gm_target or ids <= 0:
gm_id = gm_id if gm_id is not None else self._role_gm_id(role)
else:
gm_id = gm_target / abs(ids)
ceiling = self.lut.max_gm_id(dtype, l_um)
if gm_id > ceiling:
gm_id, capped = ceiling, True
gm_id = max(gm_id, float(self.lut.gm_id_axis[0]))
if gm_id_min is not None:
gm_id = max(gm_id, gm_id_min)
idw = self.lut.id_per_w(dtype, gm_id, l_um)
w_um = abs(ids) / idw if idw > 0 else self.tech.width.max
Traced with real numbers — a signal device
The sizer wants gm_target = 1.5 mA/V at Id = 100 µA, role signal:
gm/Id = 1.5m / 100µ = 15 /V
ceiling (table) = 25 /V → 15 < 25, no clamp
L = role_length = 2 × L_min, snapped
id/W (table @ 15,L) = 8 µA/µm
W = 100µ / 8µ = 12.5 µm → GeomResult(W=12.5, L, gm/Id=15, capped=False)
And a current source at the same current takes the other branch: no gm target, so gm/Id = cs_gmid = 10, a longer L = 4×L_min, a higher id/W ≈ 30 µA/µm, giving a much smaller W ≈ 3.3 µm. Same current, very different transistor — because the roles want different physics.
The honest edges
- The ceiling clamp. If the asked-for gm/Id exceeds the table's weak-inversion max, it's clamped and
gm_id_capped=Trueis flagged — the caller is told the design will fall short of its gm rather than being handed a fantasy. - The axis floor.
max(gm_id, gm_id_axis[0])keeps the value on-table at the strong-inversion end too. gm_id_min. A floor that raises gm/Id regardless of role — used to cap Vdsat for output swing. Raising a solved signal gm/Id is spec-safe:gm=(gm/Id)·Idonly grows.- Overrides. Explicit
gm_id/l_umfrom the block registry let a designer pin a device's region or length;Nonefalls back to policy, so default behaviour is unchanged. - The
idw <= 0guard. A pathological table read falls back towidth.maxrather than dividing by zero.
Looking ahead: Part 8 names the hidden idea that ties the whole file together — and traces the same gain formula through both backends to prove the socket really is model-independent.
Part 8 · The hidden idea: polymorphism, and one selector
The last function is the smallest and does the most:
def build_device_model(tech) -> DeviceModel:
if getattr(tech, "gmid_lut", None):
return GmIdModel(tech, GmIdLut(tech.gmid_lut))
return Level1Model(tech)
One question — "does this tech ship a LUT?" — picks the plug. From that line on, the rest of the sizer holds a DeviceModel and never asks again.
The hidden idea, named
Although the file never writes the word, its entire reason to exist is polymorphism: the topology relationships are model-independent, so if the primitives hide behind one interface, a single sizing-and-metrics codebase serves both technologies. The clean way to see it is to run the same single-stage gain formula through each backend and watch only the numbers differ:
Aᵥ = gm/gds. Level-1's optimistic λ gives 63; the LUT's measured intrinsic gain gives a realistic 20. The sizer's code is byte-identical either way.That is the whole payoff. The gain, GBW, phase-margin, and CMRR code in sizer.py calls model.gm(...) and model.gds(...) and never learns which plug answered — yet the LUT path returns the honest, silicon-calibrated number while the Level-1 path stays regression-identical to the legacy behaviour.
What it honestly does not do
Level1Model'sgdsignores geometry entirely (λ·Id), and itsgm_ceilingis a flat25·Idheuristic — the known price of the square law, kept for regression safety.- Only
GmIdModelcarriesgeometry_for,role_length, and a policy — Level-1 has no notion of role, because its primitives don't depend on operating point. - The gm/Id path is only as good as its committed table; outside the table's
(gm/Id, L)range it clamps to the axis edges rather than extrapolating. - The primitives are small-signal evaluations; whether a stacked device actually fits under the supply is a separate concern (that's
bias.py).
Why it's elegant
One Protocol, one selector, and a strict discipline that the six primitives are the only thing that differs between technologies. Everything model-independent — every op-amp formula worth writing — is written exactly once, against the socket. Adding a third technology model tomorrow means writing a third plug and touching nothing else.
In one sentence: device_model.py defines a single small-signal DeviceModel socket and two interchangeable plugs — a regression-safe Level-1 square law and a silicon-calibrated gm/Id lookup table (which also knows how to invert a gm target back into a (W, L)) — so that CircuitGenome's model-independent op-amp topology math is written once and serves both technologies through one polymorphic interface.