models.py — explained

circuitgenome/sizer/shared/models.py — the plain, logic-free data records that flow between the sizer's pipeline stages.

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 nmos MosfetParams pmos MosfetParams width GridSpec length GridSpec cap GridSpec spice_lib SpiceLib? + paths, maps, device flags SizingSpec vdd · vss · ibias · cl required gain · gbw · PM · slew · power … optional — None = unconstrained size_circuit() the sizer SizingResult transistors dict[str, TransistorSizing] → TransistorSizing card cc_pf · cc2_pf metrics·margins solver_status bias_feasible warnings · resistors · intents
The two hand-off crates. 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:

atoms Mosfet · Grid pointer SpiceLib aggregates in Tech · Sizing spec results out Sizing · Result
Our path through the file: build the vocabulary of atoms, then watch them nest into the crates the pipeline actually ships.

Looking ahead: Part 2 opens the two smallest crates — MosfetParams (three process numbers) and GridSpec (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.

FieldMeaningExample (NMOS)
mu_coxHow much current per volt² the device makes200e-6 A/V²
vthTurn-on threshold; sign encodes polarity0.45 V
lamOutput-conductance / finite-rout term0.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
0.5 min step 0.5 4.5 max asked 2.34 µm snaps → 2.5 µm
A 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:

spice_model (e.g. PTM) flat .include of a file with .model nmos / pmos → devices are M elements no device_map needed the simple path spice_lib (foundry PDK) .lib "file" corner devices are subcircuits → instantiated with X needs device_map + handle GF180, sky130 …
Two ways to get transistors. A PDK selects a corner and its devices are subcircuits (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.

TechParams gmid_lut present? gm/Id procedural the LUT path yes spice_lib present? no PDK subcircuit (X) SPICE verify yes spice_model present? no .model verify (M) PTM path yes Level-1 analytical no
Same crate, four destinies. Which optional asset is filled in silently decides which engine sizes and verifies the circuit — the record doubles as the router.

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 where None means "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.

required — always given vdd — positive supply vss — negative supply (0 if single) ibias — input bias current cl — output load capacitance + current ratios (default 2.0 / 5.0) optional — None = skip gain_min_db gbw_min_hz phase_margin_min_deg slew_rate_min_vps power_max_w swing · cmrr · psrr …
Two zones in one record. Solid box: the conditions the sizer needs to run at all. Dashed box: bounds it checks only when you fill them in.

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 — TransistorSizing and the aggregate SizingResult — 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.

dict keys (device ref) "m1_input_pair" "m2_input_pair" "m5_tail" TransistorSizing ref = "m1_input_pair" w_um = 12.4 l_um = 0.5 ids_a = 20e-6 vgs_v = 0.72 vds_sat_v = 0.15
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.

FieldWhat it holds
transistorsThe per-device dict above — the heart of the result
cc_pf / cc2_pfCompensation cap values, or None for single-stage
metricsComputed performance, e.g. {"gain_db": 90.1, "gbw_hz": 3e6}
marginsactual/spec per constraint; a value > 1 means the spec is met
solver_statusOR-Tools string: OPTIMAL, FEASIBLE, INFEASIBLE, UNKNOWN
bias_feasibleThe gm/Id DC verdict — False when the metrics are optimistic
warnings / resistors / transistor_intentsAdvisory 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:

every hand-off between stages is a named, typed record — never a loose dict

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.

load config size circuit report / verify TechParams · SizingSpec SizingResult
The records are the only thing that crosses between stages. Swap a stage's insides freely — the typed seam is the contract that holds the pipeline together.

What this file honestly does not do

  • No validation. Nothing here checks that vdd > vss or 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 of 2.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.