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.
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() 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
SizingSpecfrom it, quietly ignoring any keys that aren't realSizingSpecfields.
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.
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.
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.
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", [])])
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 in | Coercion | TechParams field |
|---|---|---|
name: generic_parameterized | str() | name = "generic_parameterized" |
nmos: {mu_cox: 270.0e-6, vth: 0.5, lam: 0.04} | _mosfet | MosfetParams(0.00027, 0.5, 0.04) |
width: {min: 1.0, max: 600.0, step: 1.0} | _grid | GridSpec(1.0, 600.0, 1.0) |
cap: {min_pf: 0.1, max_pf: 50.0, step_pf: 0.1} | rename | GridSpec(0.1, 50.0, 0.1) |
| (no spice_model / gmid_lut / spice_lib) | default | None, None, None |
| (no wl_units) | .get | wl_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.
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:
| Door | Unknown key | Missing required | Why |
|---|---|---|---|
load_spec | silently dropped | TypeError from the constructor | hand-annotated specs must tolerate extra notes |
load_tech | silently ignored | KeyError 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"]) orTypeError. Loud and immediate — which is the point. - Invalid value →
float("abc")raisesValueError. No custom message; you get Python's. - An unknown nickname → if
tech_foo.yamldoesn't exist, resolution falls through andopen(path)raisesFileNotFoundErroron 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
vthof 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.