Part 1 · Why this file even exists
The one job: give every stage of the sizer a small set of named boxes to put data in, so the phases can hand work to each other without agreeing on the shape of a loose dictionary.
Here is the whole story without touching a line of code.
The Initial Sizing module is a pipeline. One stage reads a YAML file and figures out the process technology. Another reads what the user wants from the amplifier. A third actually solves for transistor widths. A fourth reports the answer. Each stage needs to hand something to the next.
You could pass a raw Python dict between them — but then every stage has to remember which keys exist, guess their types, and pray nobody typos "vdd" as "vdd_v". That's how pipelines rot.
Think of these dataclasses as the labelled shipping crates on an assembly line. Instead of tossing loose parts down the belt in a cardboard box, each hand-off is a crate with printed compartments: this slot holds the supply voltage, that slot holds the load capacitance. Anyone opening the crate knows exactly what's inside and where — the editor even autocompletes the compartments for you.
So this file carries no logic at all. Every structure here is a plain @dataclass — a record type, nothing more. It defines the typed seams between stages: the specs going in, the sizings coming out.
The cast of characters
Not functions calling functions — these are records nesting records. Two big crates matter most: TechParams (everything about the process) on the way in, and SizingResult (the sized circuit) on the way out. Everything else is a small crate that lives inside one of them. Hover a crate to trace its hand-off arrow.
TechParams and SizingSpec go into the sizer; SizingResult comes out. Notice the nesting: an aggregate is just smaller records in named slots.Read the diagram as an entity-relationship sketch, not a call graph. An arrow from a small crate into a big one means "lives inside." TechParams contains two MosfetParams, three GridSpecs, and (optionally) one SpiceLib. SizingResult contains a whole dictionary of TransistorSizing records, one per device.
How we'll learn them
We'll go smallest crate first, biggest last — the opposite of the file's own order, because you can't understand the aggregate until you know the parts it nests. That means:
Looking ahead: Part 2 opens the two smallest crates —
MosfetParams(three process numbers) andGridSpec(a min/max/step ruler). Everything bigger is built from these.
Part 2 · The atoms — MosfetParams & GridSpec
The one job: hold the two smallest, most-reused facts about a process — the electrical character of one transistor polarity, and the discrete ruler that legal geometries snap to.
MosfetParams — three numbers that describe a device
@dataclass
class MosfetParams:
mu_cox: float # µ·Cox, the transconductance parameter, in A/V²
vth: float # threshold voltage in V (+ for NMOS, − for PMOS)
lam: float # channel-length modulation λ, in 1/V
Analogy: a transistor is like a car engine, and these three numbers are the spec-sheet line items — how hard it pulls (mu_cox), the ignition point it won't run below (vth), and how much it "leaks" torque as load rises (lam). The analytical (Level-1) sizer plugs exactly these three into the Shichman–Hodges equations to size a device on paper, with no SPICE at all.
| Field | Meaning | Example (NMOS) |
|---|---|---|
mu_cox | How much current per volt² the device makes | 200e-6 A/V² |
vth | Turn-on threshold; sign encodes polarity | 0.45 V |
lam | Output-conductance / finite-rout term | 0.10 /V |
A whole TechParams carries two of these — one for nmos, one for pmos — because the two polarities have genuinely different physics (the PMOS vth is negative).
GridSpec — a ruler with three ticks that matter
@dataclass
class GridSpec:
min: float # smallest legal value (µm for W/L, pF for cap)
max: float # largest legal value
step: float # discretisation step
Analogy: you can't order lumber at any length — the yard sells it in fixed increments, with a shortest and longest board. GridSpec is that catalogue: "widths from 0.5 to 4.5 µm, in 0.5 µm steps." The solver is only ever allowed to pick a value that lands on a tick and stays inside the two ends.
Traced with real numbers
Take a width grid GridSpec(min=0.5, max=4.5, step=0.5). Suppose the sizer computes an ideal width of 2.34 µm. It isn't legal — so it snaps to the nearest tick:
round(2.34 / 0.5) * 0.5 = round(4.68) * 0.5 = 5 * 0.5 = 2.5 µm
GridSpec is just a ruler: min and max are the ends, step the tick spacing. Any computed value lands on the nearest tick.The same three-number record is reused for three different quantities in TechParams: transistor width, transistor length, and compensation cap. One tiny class, three jobs — that's the payoff of a well-chosen atom.
Looking ahead: Part 3 meets the one atom that isn't just numbers —
SpiceLib, a pointer to a foundry PDK on disk, and the reason a couple of extra fields ride alongside it.
Part 3 · SpiceLib — a pointer to a real foundry kit
The one job: remember where a foundry PDK lives on disk and which process corner to use, so the SPICE-verification step can build a netlist against the real device models.
@dataclass
class SpiceLib:
file: str # path to the corner library
corner: str = "typical" # nominal corner for sizing
design: str | None = None # optional global settings to .include
corners: list[str] = field(default_factory=list) # corners to re-measure in the CLI
Analogy: MosfetParams was the engine spec printed on the page; SpiceLib is a library card that says "the real, exhaustive manual is on shelf sm141064.ngspice, and read the typical edition." The data itself lives elsewhere; this record just holds the reference.
Why a PDK needs more than a flat model file
This is the subtlety worth slowing down for. There are two totally different ways a technology can supply its transistors, and SpiceLib exists to mark the harder one:
X), so a SpiceLib always drags along a device_map — which is why those extra TechParams fields exist.So SpiceLib is small, but its presence tells the rest of the pipeline "we're in subcircuit-land now." That ripples into TechParams, which we open next.
The field(default_factory=list) detail
Notice corners uses field(default_factory=list), not corners = []. Why? A bare [] default would be shared by every instance — a classic Python trap where two SpiceLib objects secretly point at the same list. default_factory=list says "make a fresh empty list for each new record." A quiet correctness detail you'll see again in SizingResult.
Looking ahead: Part 4 assembles all the atoms into
TechParams— the big incoming crate — and shows how its optional fields silently pick which sizing engine runs.
Part 4 · TechParams — everything the process gives us
The one job: bundle a complete process technology — its two device models, its three geometry grids, and whatever SPICE/gm-Id assets exist — into one crate loaded from a YAML config.
This is the first aggregate: a record whose fields are other records. We've already met every part, so the whole is easy now.
@dataclass
class TechParams:
name: str
nmos: MosfetParams # the two atoms from Part 2
pmos: MosfetParams
width: GridSpec # the three rulers
length: GridSpec
cap: GridSpec
spice_model: str | None = None # flat .model card (PTM)
gmid_lut: str | None = None # committed gm/Id lookup table
spice_lib: SpiceLib | None = None # the PDK pointer from Part 3
device_map: dict[str, str] | None = None
device_handle: dict[str, str] | None = None
wl_units: str = "m"
The first six fields are required and describe every technology: a name, both polarities, and the three grids. Everything after is optional plumbing that only some technologies carry.
The hidden idea: the optional fields choose the engine
Here's what makes TechParams more than a passive bag. Three of its optional fields — gmid_lut, spice_lib, spice_model — quietly route the whole pipeline to a different sizer. The data is the switch.
The __future__ import at the top of the file is what lets those str | None annotations work as plain strings (they're never evaluated), and device_handle / wl_units exist only to paper over foundry quirks — GF180 names its device instance m0 and expects SI units; sky130 names it after the cell and uses bare microns. Plumbing, honestly labelled.
Looking ahead: Part 5 turns to the other incoming crate —
SizingSpec, where the user says what the amplifier must achieve, and whereNonemeans "don't care."
Part 5 · SizingSpec — the wish list
The one job: capture what the designer asks of the amplifier — the operating conditions that are fixed, and the performance bounds that are optional targets.
Where TechParams says "here's the silicon you have," SizingSpec says "here's what I want out of it." It's a flat record — no nesting — but it has a strong shape: a handful of required facts and a long tail of optional constraints.
@dataclass
class SizingSpec:
vdd: float # required: operating conditions
vss: float
ibias: float
cl: float
second_stage_current_ratio: float = 2.0 # sensible defaults
third_stage_current_ratio: float = 5.0
gain_min_db: float | None = None # optional: None = unconstrained
gbw_min_hz: float | None = None
phase_margin_min_deg: float | None = None
slew_rate_min_vps: float | None = None
power_max_w: float | None = None
# … output_swing, cmrr_min_db, psrr_min_db — all Optional
Analogy: it's a restaurant order. Four things you must state — table (supply rails), party size (bias current), and what's on the table already (load cap). Everything else is a preference: "at least 90 dB of gain," "no more than 2 mW of power." Leave a preference blank and the kitchen simply won't check it.
Traced with real numbers
A 5 V single-supply op-amp driving 10 pF, asking for at least 90 dB gain and 3 MHz bandwidth, silent on everything else:
SizingSpec(vdd=5.0, vss=0.0, ibias=10e-6, cl=10e-12,
gain_min_db=90.0, gbw_min_hz=3.0e6)
# phase_margin_min_deg, power_max_w, cmrr_min_db … all stay None → not enforced
The None convention is doing real work: it lets one record express "constrain gain and bandwidth, but I don't care about CMRR" without any special flags. Absence is the signal.
Looking ahead: Part 6 opens the crate that comes back out —
TransistorSizingand the aggregateSizingResult— and names the single idea this whole file quietly enforces.
Part 6 · The outputs, the exception, and the hidden idea
The one job: package the sizer's answer — every transistor's dimensions plus circuit-level results and an honest verdict — into one crate the caller can trust and print.
TransistorSizing — the answer for one device
@dataclass
class TransistorSizing:
ref: str # netlist reference, e.g. "m1_input_pair"
w_um: float # gate width in µm
l_um: float # gate length in µm
ids_a: float # quiescent drain current in A
vgs_v: float # quiescent gate-source voltage
vds_sat_v: float # minimum |Vds| for saturation
This is the mirror image of MosfetParams. That atom described a device type in the abstract; this one describes a specific, sized transistor in the final circuit — its geometry and its DC operating point. And a whole amplifier has many of them, so SizingResult keeps a dictionary keyed by ref.
SizingResult.transistors is a phone book: look up a device by its netlist name, get back the full sizing card. One TransistorSizing per transistor.SizingResult — the whole answer
The final aggregate gathers the per-device dictionary plus circuit-level results. Its required fields carry the core answer; its field(default_factory=…) fields carry the extras that may legitimately be empty.
| Field | What it holds |
|---|---|
transistors | The per-device dict above — the heart of the result |
cc_pf / cc2_pf | Compensation cap values, or None for single-stage |
metrics | Computed performance, e.g. {"gain_db": 90.1, "gbw_hz": 3e6} |
margins | actual/spec per constraint; a value > 1 means the spec is met |
solver_status | OR-Tools string: OPTIMAL, FEASIBLE, INFEASIBLE, UNKNOWN |
bias_feasible | The gm/Id DC verdict — False when the metrics are optimistic |
warnings / resistors / transistor_intents | Advisory text, sized resistors, per-device design intent — often empty |
Note margins: because it stores actual/spec, reading the result never requires re-checking the spec — a single number tells you both "did it pass?" (> 1) and "by how much?" This is the same "let the data answer the question" spirit as SizingSpec's None.
UnsupportedTechError — the one non-record
The file's only class that isn't a dataclass is a small exception:
class UnsupportedTechError(ValueError):
"""Raised when a technology can't be sized by the requested method."""
It's here for the same reason as the records: it's part of the vocabulary of the seam. When a PTM/SPICE node has no gm/Id LUT, the analytical sizer isn't a valid fallback — so rather than return a garbage SizingResult, a stage raises this specific error and the caller can catch exactly it. A named failure is as much a contract as a named field.
The hidden idea: typed seams and data contracts
Step back and the whole file is implementing one principle it never states:
That's the payoff of a file with no logic. Each stage depends only on the shape of these crates, not on the internals of the stage before it. You can rewrite the gm/Id sizer completely, and as long as it still returns a SizingResult, every downstream printer, verifier, and CLI keeps working untouched. The dataclasses are the stable interface; the logic around them is free to churn.
What this file honestly does not do
- No validation. Nothing here checks that
vdd > vssor that a width lands on the grid — these are dumb records; the surrounding stages enforce meaning. - Not actually immutable. They're plain dataclasses, not
frozen=True. The docstring's "freely inspected or passed" is a convention, not an enforced guarantee — a stage could mutate one, and nothing would stop it. - No behaviour. There are no methods. Any computation over these lives elsewhere; this file is deliberately inert so it can be imported anywhere without side effects.
- Defaults, not intelligence.
corner="typical",wl_units="m", ratios of2.0/5.0— reasonable starting points, but the file never reasons about whether they fit your process.
Why it's elegant
The whole design rides one restraint: keep the data types free of logic so they can be the shared language of every stage. Small atoms (MosfetParams, GridSpec) compose into aggregates (TechParams, SizingResult); optional fields double as routers; None and empty collections carry meaning without flags. It's the least code in the module and the most important, because everything else agrees to speak through it.
In one sentence: models.py defines the logic-free dataclasses that are the typed seams of the sizer — small atoms (MosfetParams, GridSpec, SpiceLib) that compose into the incoming crates (TechParams, SizingSpec) and the outgoing crate (SizingResult, holding one TransistorSizing per device) — so every pipeline stage hands work to the next through a stable, named contract instead of a loose dictionary.