loader.py — explained

circuitgenome/sizer/shared/loader.py — the one door that turns human-written YAML config into typed, validated Python objects the sizer can trust.

Part 1 · Why this file even exists

The one job: take a human-friendly YAML config file and hand back a typed, validated Python object — so the rest of the sizer never has to touch a raw dictionary or guess whether a value is a string or a number.

Here is the whole story without a line of code.

The sizer needs process facts — threshold voltages, width grids, capacitor ranges, file paths to SPICE models. Those live in .yaml files that a human writes and edits: with comments, extra annotation keys, numbers typed as 270.0e-6, and paths written relative to the config folder. That is the messy outside world.

The sizer's inside world is the opposite: strict, typed @dataclass objects (TechParams, SizingSpec, GridSpec …) where vth is guaranteed to be a float and a path is guaranteed to be absolute. Everything downstream leans on that guarantee.

Think of loader.py as a customs officer at a border. Travellers (YAML documents) arrive with paperwork in every format. The officer reads each document, converts every value into the country's official currency (float(), str()), stamps the required fields, waves through the harmless extras, and hands the sizer a clean, typed passport. Nothing gets past this desk untyped.

messy outside clean inside config.yaml human-written yaml.safe_load → raw dict coerce + check float(), keys TechParams typed ✓ the border desk
One direction, one desk: a human YAML file goes in, a typed dataclass comes out — validated on the way through.

What breaks without this file? The sizer would reach into a raw dict, guess key names, and eventually feed a string "0.5" into a numeric routine ten stack-frames deep — a crash with no hint of the real cause (a typo in a config file). The loader pulls all of that risk to one place, at the moment the config is read.

The cast of characters

Two public front doors, three tiny private helpers. Hover any box to trace who it calls.

load_spec() YAML → SizingSpec (lenient) load_tech() YAML → TechParams (strict) _mosfet() dict → MosfetParams _grid() dict → GridSpec _resolve() rel path → absolute
Two independent entry points. load_spec() stands alone; load_tech() leans on three one-line coercion helpers. We'll learn the leaves as they appear.

Looking ahead: Part 2 takes the smaller door first — load_spec() — whose whole trick is a single lenient one-liner.

Part 2 · load_spec() — the lenient parser

The one job: read a spec YAML file and build a SizingSpec from it, quietly ignoring any keys that aren't real SizingSpec fields.

def load_spec(path):
    with open(path) as f:
        data = yaml.safe_load(f)
    return SizingSpec(
        **{k: v for k, v in data.items()
           if k in SizingSpec.__dataclass_fields__})

Analogy: a form-parser that skips blank lines it doesn't recognise. You hand it a form with the four required boxes filled in — plus some scribbled notes in the margin. It copies the boxes it knows about and simply doesn't read your margin notes. No error, no fuss.

The single most important line

Everything happens in that dict comprehension. SizingSpec.__dataclass_fields__ is a dict Python auto-generates for every @dataclass — its keys are the legal field names. So if k in SizingSpec.__dataclass_fields__ is a sieve: a YAML key survives only if it names a real field. Unknown keys fall through the holes and vanish before they ever reach the constructor.

YAML keys SizingSpec vdd ibias cl provenance author k in fields? vdd ibias cl dropped dropped
Known field names pass into the constructor; extras like provenance hit the sieve and are silently discarded.

Traced with a real file

Say the spec YAML is:

vdd: 5.0
vss: 0.0
ibias: 1.0e-5
cl: 10.0e-12
provenance: "hand-tuned 2026"   # not a SizingSpec field

After yaml.safe_load that's a dict of five keys. The comprehension keeps the four that name fields and drops provenance, so the call is effectively:

SizingSpec(vdd=5.0, vss=0.0, ibias=1e-05, cl=1e-11)
# every other SizingSpec field takes its dataclass default (None / 2.0 …)

Why be lenient here? A spec file is something an engineer annotates by hand — dropping in comments, provenance, a scratch note. Leniency means those annotations never break loading. The cost: a typo in a real field name (say vddd) is silently ignored too — we'll flag that honestly in Part 5.

Part 3 · load_tech() — first, find the file

The one job: before parsing anything, decide which file to actually open — a built-in default, a real path the user gave, or the short name of a bundled config.

load_tech() accepts path=None, a real filesystem path, or a friendly short name like "ptm45". That convenience means it has to resolve three cases up front:

if path is None:
    path = _BUILTIN_DIR / "tech_generic.yaml"
elif not Path(path).exists():
    # not a real path → treat as a built-in name ("ptm45" → tech_ptm45.yaml)
    builtin = _BUILTIN_DIR / f"tech_{Path(path).name}.yaml"
    if builtin.exists():
        path = builtin

Analogy: you walk up to the officer and either say nothing (they hand you the standard form), show a real document (they take it as-is), or say a nickname like "the 45-nanometre one" — and they walk to the shelf and pull tech_ptm45.yaml for you.

load_tech(path) path is None path exists neither tech_generic.yaml the default form open path as-is your document tech_{name}.yaml bundled by nickname
Three ways in, one file out. The middle branch fires when the string really is a file on disk; the right branch is the "nickname" convenience.

The three tiny helpers it defines

Once the file is open and parsed, load_tech defines three closures. Each is a one-liner whose only job is type coercion — turning a YAML sub-dict into a typed dataclass, forcing every number through float().

def _mosfet(d):
    return MosfetParams(
        mu_cox=float(d["mu_cox"]),
        vth=float(d["vth"]),
        lam=float(d["lam"]))

def _grid(d, min_key="min", max_key="max"):
    return GridSpec(
        min=float(d[min_key]),
        max=float(d[max_key]),
        step=float(d["step"]))

Why wrap float() around values YAML already parsed? Because you can't trust that YAML gave you a number. Write vth: 0.5 and you get a float; write vth: "0.5" or vth: 5e-1 in a way the parser reads as a string, and you'd get a str. The explicit float() is the officer converting every value to the official currency, no exceptions. Note d["mu_cox"] uses [], not .get() — these fields are required, so a missing one should raise loudly.

The third helper resolves relative paths against the config file's own directory:

def _resolve(rel):
    if not rel:
        return None
    p = Path(rel)
    if not p.is_absolute():
        p = Path(path).parent / p
    return str(p)

A config writes gmid_lut: models/gf180mcu_gmid.npz — relative to where the config lives, not to wherever you happened to run the program. _resolve glues that onto Path(path).parent so the sizer always gets an absolute path that works no matter the current directory. Absolute paths are passed through untouched; empty/None comes back as None.

Part 4 · load_tech() — building the object

The one job: assemble one big typed TechParams — every required field coerced through a helper, every optional block filled in only if the YAML provides it.

With the file open and the helpers defined, the payoff is one large constructor call. Here is the shape of it, trimmed to the essentials:

return TechParams(
    name=str(data["name"]),
    nmos=_mosfet(data["nmos"]),
    pmos=_mosfet(data["pmos"]),
    width=_grid(data["width"]),
    length=_grid(data["length"]),
    cap=GridSpec(
        min=float(cap_d["min_pf"]),   # note the rename!
        max=float(cap_d["max_pf"]),
        step=float(cap_d["step_pf"])),
    spice_model=spice_model,       # resolved path or None
    gmid_lut=gmid_lut,
    spice_lib=spice_lib,
    wl_units=str(data.get("wl_units", "m")))

Notice the one place a name is remapped. The YAML writes the cap grid as min_pf / max_pf / step_pf (units baked into the key, friendly for humans), but GridSpec just wants min / max / step. That's why cap is built by hand instead of via _grid — its keys don't match the helper's defaults. Everything else lines up one-to-one.

YAML dict TechParams name: generic… nmos: {…} width: {…} cap: {min_pf…} (no spice_model) str() _mosfet() _grid() rename + float() default None .name (str) .nmos .width .cap (min/max) .spice_model=None
Mostly one-to-one, coerced in flight. The accent row is the single renamed field; the muted row is an absent optional falling back to None.

Optional blocks: fill only if present

Required fields use data["x"] (crash if absent). Optional blocks use data.get("x") and a guard, so a config that doesn't need them simply leaves them None:

spice_lib = None
lib_d = data.get("spice_lib")
if lib_d:
    spice_lib = SpiceLib(
        file=_resolve(lib_d["file"]),
        corner=str(lib_d.get("corner", "typical")),
        design=_resolve(lib_d.get("design")),
        corners=[str(c) for c in lib_d.get("corners", [])])
spice_lib in YAML? yes no spice_lib = None SpiceLib(...) with resolved paths
The PDK block is optional: a PTM-style config skips it and gets None; a foundry config builds a full SpiceLib.

Traced with a real file: tech_generic.yaml

Feed in the bundled generic config and watch the fields populate:

YAML inCoercionTechParams field
name: generic_parameterizedstr()name = "generic_parameterized"
nmos: {mu_cox: 270.0e-6, vth: 0.5, lam: 0.04}_mosfetMosfetParams(0.00027, 0.5, 0.04)
width: {min: 1.0, max: 600.0, step: 1.0}_gridGridSpec(1.0, 600.0, 1.0)
cap: {min_pf: 0.1, max_pf: 50.0, step_pf: 0.1}renameGridSpec(0.1, 50.0, 0.1)
(no spice_model / gmid_lut / spice_lib)defaultNone, None, None
(no wl_units).getwl_units = "m"

Out comes a fully typed TechParams — every number a genuine float, every path absolute, every absent option a clean None. The sizer can now trust it completely.

Part 5 · The hidden boundary, limits, and the one line

Although the file never names it, loader.py implements one classic idea: a single validated boundary — the anti-corruption layer — between the messy config world and the clean typed core.

messy YAML  ──( one door )──▶  typed, trusted dataclasses

Every guarantee the sizer relies on is established here and nowhere else. Downstream code never re-checks types, never resolves a path, never guesses a key — because it can't receive anything that wasn't already coerced and validated at this desk.

The lenient/strict split

The two doors deliberately behave differently, and it's worth naming why:

DoorUnknown keyMissing requiredWhy
load_specsilently droppedTypeError from the constructorhand-annotated specs must tolerate extra notes
load_techsilently ignoredKeyError from data["x"]a broken process file must fail loudly, early

What it honestly does not do

  • Missing required field → a raw KeyError (e.g. data["name"]) or TypeError. Loud and immediate — which is the point.
  • Invalid valuefloat("abc") raises ValueError. No custom message; you get Python's.
  • An unknown nickname → if tech_foo.yaml doesn't exist, resolution falls through and open(path) raises FileNotFoundError on the original string.
  • A typo in an optional key (e.g. vddd, spicemodel) is silently dropped — no schema validation catches it. This is the price of leniency.
  • No range or physical-sanity checks — a negative width or vth of 900 V loads happily. Sanity is someone else's job.

Why it's elegant

The whole file rides one discipline: convert and validate exactly once, at the edge. Required fields crash loudly, optional fields default quietly, paths resolve relative to their own config, and every number is forced through float() — so the hundreds of lines of sizing code behind this desk can assume perfect, typed inputs and never look back.


In one sentence: loader.py is the single validated border between human-written YAML and the sizer's typed core — load_spec() leniently builds a SizingSpec, load_tech() strictly builds a TechParams (resolving built-in names and relative paths, coercing every value through float()/str(), and filling optional blocks only when present) — so that everything downstream can trust its inputs completely.