Part 1 · Why this file even exists
The one job: be a printed table of how real transistors behave, so the sizer can look up device physics instead of guessing it from textbook square-law formulas.
Here is the whole story without a line of code.
An analog sizer constantly needs to ask questions like "at this operating point, how much current per micron does the device carry? what's its saturation voltage? its gain?" The classic answer is to plug numbers into the square-law equations from a textbook — gm ≈ √(2·µCox·(W/L)·Id) and friends. Those formulas are clean, but on a modern PTM (Predictive Technology Model) node they are simply wrong: short-channel effects, velocity saturation, and sub-threshold conduction make the real silicon behave nothing like the ideal square law.
The old code even hard-coded a crude ceiling for weak-inversion behaviour — the 25·Id heuristic — as a stand-in for "the most efficient a device can get." A single magic number, standing in for an entire regime of physics.
Think of it like the difference between estimating a logarithm in your head versus reading it off a printed log table. The mental estimate is fast and roughly right; the printed table is the measured truth, worked out once by someone careful, and you just look up the row and column. This file is that printed table — built by measuring BSIM4 (the real device model) on a grid, saved to a *_gmid.npz file, and committed so every run reads the same measured truth.
So gmid_lut.py is a small, quiet oracle. Hand it a device polarity, an operating point (gm/Id, L), and it hands back the measured physics — smoothly interpolated between the grid points it was built from.
The cast of characters
One class, a handful of methods. Two private helpers do all the real work; everything public is a thin wrapper. Hover any box to trace who it calls.
_lookup and _row. Learn those two and the file is yours.Looking ahead: before any code, Part 2 pins down the shape of the table — the
(gm/Id, L)grid, its two axes, and its five fields — because every method is just a walk over that grid.
Part 2 · The table's shape (one grid, two axes, five fields)
Everything else is interpolation over this grid. Get the grid in your head first.
Picture a road map with a coordinate grid printed on it. To find the elevation at some point, you look up the nearest grid intersections and read the printed numbers. Our table is exactly that, but the two coordinates are analog-design knobs:
- gm/Id (the x-axis) — the transistor's efficiency, in
1/V. Low = strong inversion, high = weak inversion. A uniform grid. - L (the y-axis) — the channel length, in
µm.
At every grid intersection the table stores five measured numbers (the fields): id_w (current per µm of width), gm_gds (intrinsic gain), ft (transition frequency), vdsat (saturation overdrive), and vgs (gate voltage). Each field is a 2-D array shaped (n_L, n_gmid) — one printed sheet per quantity — and there are two full sets, one for nmos and one for pmos.
vdsat in volts — printed on the (gm/Id, L) grid. Down a column Vdsat rises with L; across a row it falls as the device weakens. There are five such sheets, times two polarities.Notice the numbers are smooth and monotone: down a column (longer channel) vdsat creeps up; across a row (higher gm/Id, weaker inversion) it falls. That smoothness is what lets us interpolate between grid points and trust the answer.
Part 3 · __init__ — reading the sheets off disk
The one job: open the committed
*_gmid.npzand lay its arrays out in memory — the two axes, and a field-by-field dictionary for each polarity.
def __init__(self, path: Path | str):
data = np.load(path)
self.gm_id_axis = data["gm_id_axis"]
self.l_axis = data["l_axis"]
self._fields = {
dtype: {f: data[f"{dtype}_{f}"] for f in _FIELDS}
for dtype in ("nmos", "pmos")
}
Analogy: you bought a folder of printed sheets. np.load opens the folder; the two axes are the row/column rulers, and the double dict-comprehension files each sheet under polarity → field. The f"{dtype}_{f}" just reconstructs the flat key the file was saved under — the arrays inside the .npz are named things like "nmos_vdsat", "pmos_id_w".
That's the entire constructor — no computation, just filing. Every method from here reaches into self._fields[dtype][field] to grab the right sheet, then uses the two axes to interpolate.
Part 4 · _row — collapse the length axis first
The one job: for a requested channel length
l_um, blend the two bracketing length-rows of a sheet into a single 1-D curve over the gm/Id axis.
Bilinear interpolation over a 2-D grid is really just two 1-D interpolations, one per axis. _row does the first one — along L — and hands back a single row so the caller only has the gm/Id axis left to deal with.
def _row(self, dtype, field, l_um):
la = self.l_axis
grid = self._fields[dtype][field] # shape (n_L, n_gmid)
l = float(np.clip(l_um, la[0], la[-1])) # clamp into the L range
j = int(np.searchsorted(la, l)) # which two rows bracket l?
if j <= 0: return grid[0].copy()
if j >= len(la): return grid[-1].copy()
l0, l1 = la[j - 1], la[j]
t = (l - l0) / (l1 - l0) if l1 > l0 else 0.0
return grid[j - 1] * (1.0 - t) + grid[j] * t
Analogy: you want the elevation profile along a latitude that falls between two printed gridlines. You take the profile on the line just north, the one just south, and average them in proportion to how close you are to each. t is that proportion.
The three guards, in plain English
np.clip— if someone asks for a length off the end of the table, pin it to the nearest edge rather than extrapolating into fantasy.searchsorted— finds the insertion indexj, so rowsj-1andjare the two that bracket the request.- the
j<=0/j>=lenearly returns — exactly on or past an edge, just hand back that edge row (copied, so a caller can't scribble on the stored table).
Traced with real numbers
Ask for vdsat at l_um = 0.27. The bracketing rows are L=0.18 and L=0.36, so:
t = (0.27 - 0.18) / (0.36 - 0.18) = 0.09 / 0.18 = 0.5
row@0.18 (over gm/Id): [ ... 0.20 (g=10), 0.12 (g=15) ... ]
row@0.36 (over gm/Id): [ ... 0.24 (g=10), 0.15 (g=15) ... ]
blended = row@0.18·(1−0.5) + row@0.36·(0.5)
= [ ... 0.22 (g=10), 0.135 (g=15) ... ]
_lookup will handle that in the second step.Part 5 · _lookup — the bilinear interpolation (the centerpiece)
The one job: read any field at any operating point
(gm_id, l_um)by finishing the second 1-D interpolation — along the gm/Id axis — on the row that_rowjust produced.
def _lookup(self, dtype, field, gm_id, l_um):
row = self._row(dtype, field, l_um) # step 1: collapse L
g = float(np.clip(gm_id, self.gm_id_axis[0], self.gm_id_axis[-1]))
return float(np.interp(g, self.gm_id_axis, row)) # step 2: interp along gm/Id
This is the single most important method in the file. Two lines of real work: get the length-blended row, then slide along it to the requested gm/Id. Two 1-D interpolations stacked = one bilinear interpolation.
The 2×2 corners, and the query between them
Take the four boxed corners from Part 2 and ask for vdsat at (gm/Id = 12, L = 0.27) — a point sitting inside the little square, touching no gridline.
Plug the real numbers through
Step 1 is exactly the Part 4 blend at t = 0.5, giving the two green mid-points:
left side (g=10): 0.20·0.5 + 0.24·0.5 = 0.22
right side (g=15): 0.12·0.5 + 0.15·0.5 = 0.135
Step 2 — np.interp along gm/Id at g = 12, which sits a fraction u = (12−10)/(15−10) = 0.4 of the way from 10 to 15:
vdsat = 0.22·(1 − 0.4) + 0.135·(0.4)
= 0.22·0.6 + 0.135·0.4
= 0.132 + 0.054
= 0.186 V ✓
And because a plane through four corners doesn't care which axis you fold first, the classic bilinear formula gives the same 0.186:
= 0.20·0.6·0.5 + 0.12·0.4·0.5 + 0.24·0.6·0.5 + 0.15·0.4·0.5
= 0.060 + 0.024 + 0.072 + 0.030 = 0.186 V ✓
The five public reads are one-line wrappers
Every public getter is the same call to _lookup with a different field string. That is the whole payoff of building _lookup well — the physics API is nearly free.
| Method | Returns | Field |
|---|---|---|
id_per_w | drain current per µm of width (A/µm) | id_w |
gm_gds | intrinsic-gain ratio gm/gds (V/V) | gm_gds |
ft | transition frequency gm/(2π·Cgg) (Hz) | ft |
vdsat | saturation overdrive VDS,sat (V) | vdsat |
vgs | gate-source voltage magnitude (V) | vgs |
Part 6 · The inverse, the ceiling, and the hidden idea
max_gm_id — the weak-inversion ceiling, measured not guessed
The one job: report the largest gm/Id the table represents — the most efficient a device can physically get.
def max_gm_id(self, dtype, l_um):
return float(self.gm_id_axis[-1])
This one line is quietly the whole point of the file. The old code guessed the weak-inversion ceiling with the magic 25·Id heuristic. Here the ceiling is simply the last tick of the measured axis — the edge of what BSIM4 actually showed. No magic number; the table is the ceiling.
gm_id_from_idw — running the table backwards
The one job: given a solved current density
id_w, recover which gm/Id produced it — the inverse ofid_per_w, used to read back a device's operating point after sizing.
def gm_id_from_idw(self, dtype, id_w, l_um):
curve = self._row(dtype, "id_w", l_um) # decreasing in gm/Id
xs = curve[::-1] # reverse → ascending current
ys = self.gm_id_axis[::-1]
return float(np.interp(id_w, xs, ys))
Why the reversal? Because id_w falls as gm/Id rises (a weaker, more efficient device carries less current per micron). But np.interp insists its x-values go ascending. Flipping both arrays turns the decreasing current curve into an ascending one so np.interp is happy — and reads the gm/Id back out.
[::-1] flip is only there to satisfy np.interp's ascending-x rule.Traced with real numbers
curve (id_w, gm/Id descending): [ 5.0 (g=10), 2.0 (g=15) ]
xs = reversed → [2.0, 5.0] ys = reversed → [15, 10]
np.interp(3.5, [2.0, 5.0], [15, 10]):
fraction = (3.5 − 2.0)/(5.0 − 2.0) = 0.5
gm/Id = 15 + 0.5·(10 − 15) = 12.5 ✓
The hidden idea
Strip away the interpolation mechanics and one sentence remains. Every method here is a read against a single measured artifact:
That artifact — the committed *_gmid.npz — is now the source of truth for device physics in the gm/Id path. It retires three square-law estimates (gm, gds, vds_sat) and the 25·Id weak-inversion ceiling, replacing all of them with numbers the transistor actually produced.
What it honestly does not do
- No extrapolation. Off-grid queries are
np.clip-ed to the edges — safe, but it will silently pin a wildly out-of-range request instead of erroring. - Only as good as the grid. Bilinear interpolation assumes the quantity is smooth between points; a coarse grid across a sharp knee would blur it.
- Monotonicity is assumed, not checked.
gm_id_from_idwtrusts thatid_wstrictly decreases with gm/Id; a non-monotone curve would make the inverse ambiguous. - Fixed bias context. The table is measured at the conditions
extract_tech.pychose; it is not a full SPICE re-simulation of the final circuit.
Why it's elegant
All the physics lives in data, and all the code is one reusable idea — bilinear interpolation — expressed as two stacked 1-D interps. _row and _lookup carry the entire module; the five getters, the ceiling, and the inverse are almost free wrappers around them.
In one sentence: gmid_lut.py loads a committed BSIM4 measurement grid and serves any device quantity at any (gm/Id, L) operating point by bilinear interpolation — plus an inverse to read gm/Id back from current density — making the table, not the square law or the 25·Id heuristic, the source of truth for device physics in the gm/Id sizing path.