# Jitx Substrate Modeler

> Use when the user asks to create a substrate, define a stackup, add via definitions, set up routing structures, configure impedance control, define differential pairs, set fabrication rules, ring a shape with fence vias, fence a pour outline, fence an antipad, model a PCB layer structure, model a substrate from a fabrication house's stackup report (CSV, PDF, or quote), or verify a substrate against its source report. Ask which fabrication house is targeted. If JLCPCB is confirmed, use available jitxlib.jlcpcb predefined substrates; otherwise create a custom substrate. Covers Stackup, Symmetric, materials, vias, routing structures, differential routing structures, NeckDown, via fencing, fenced pours, geometry, reference planes, and FabricationConstraints.

- Skill: `jitx-inc/jitx-substrate-modeler` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add jitx-inc/jitx-substrate-modeler`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jitx-inc/jitx-substrate-modeler/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: JITx-Inc (https://skillmd.com/u/jitx-inc)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jitx-inc/jitx-substrate-modeler

---


# JITX Substrate Modeler

Generate complete JITX Python substrate definitions — stackups, materials, vias, routing structures, and fabrication constraints — all in a single file.

A substrate task is **not complete** until the **Substrate completeness check** block (near the end of this skill) is filled out, row by row, in your completion summary. Prose that paraphrases some of its rows is not the block. Where the base `jitx` skill's task-acceptance block is in play, embed this block inside it rather than producing two competing completion artifacts. No filled block, no "done".

## Predefined Substrates (JLCPCB Only)

If the user has confirmed they are targeting **JLCPCB** as their fabrication house, predefined substrates from `jitxlib.jlcpcb` are available. These are production-validated with correct materials, vias, fab rules, and impedance-matched routing structures:

| Class | Layers | Prepreg | Routing Structures | Import |
|-------|--------|---------|-------------------|--------|
| `JLC04161H_1080` | 4 | 1080 | RS_50, DRS_90, DRS_100 | `from jitxlib.jlcpcb import JLC04161H_1080` |
| `JLC04161H_7628` | 4 | 7628 | RS_50, DRS_90, DRS_100 | `from jitxlib.jlcpcb import JLC04161H_7628` |
| `JLC06161H_7628` | 6 | 7628 | RS_50, DRS_100 | `from jitxlib.jlcpcb import JLC06161H_7628` |

Each includes: Symmetric stackup, JLCPCBRules (FabricationConstraints), 9 JLCPCB via definitions (StdVia, StdViaPreferred, MultiLayerVia1-3 + Preferred variants, StdViaTentedFilled for via-in-pad), and routing structures for 50/90/100 ohm impedance targets.

**Use directly** — no substrate file needed:
```python
from jitxlib.jlcpcb import JLC04161H_1080
substrate = JLC04161H_1080()

# Access routing structures for SI constraints:
# substrate.RS_50, substrate.DRS_90, substrate.DRS_100
```

**When to use predefined:** User has explicitly confirmed JLCPCB as fab house + 4 or 6 layer FR-4 + standard impedance targets (50/90/100 ohm). This covers USB, Ethernet, I2C, SPI, I2S, and most common protocols.

**When to create custom (use the rest of this skill):** User has not confirmed JLCPCB, non-FR-4 materials (Rogers, Megtron), unusual layer count, non-standard impedance, or additional routing structures needed. **This is the default path** — always create a custom substrate unless the user opts in to a predefined one.

## Environment

Environment setup is handled by the base `jitx` skill. Ensure it has been invoked first.

## Package Architecture

```python
# Core imports — use these exactly
from jitx.stackup import Stackup, Symmetric, Conductor, Dielectric
from jitx.substrate import Substrate, FabricationConstraints
from jitx.via import Via, ViaType, ViaDiameter, Backdrill, BackdrillSet
from jitx.si import RoutingStructure, DifferentialRoutingStructure, symmetric_routing_layers
from jitx.layerindex import Side, LayerSet
from jitx.units import ohm
from jitx.constraints import ViaFencePattern
from jitx.feature import KeepOut, Soldermask
from jitxlib.physics import phase_velocity
from jitx.container import inline

```

**These DO NOT EXIST — never import:**
`jitx.material`, `jitx.layer`, `jitx.routing`, `jitx.impedance`, `jitx.pcb`,
`jitx.dielectric`, `jitx.conductor`, `jitxlib.stackup`, `jitxlib.substrate`

## Anti-string-hacking — read before adding per-layer / per-via tables

Substrate-shaped data (layer-to-via maps, layer-pair tables, per-layer trace widths) belongs **on the substrate**, queried by the design — not duplicated as design-level constants. The design should write `self.substrate.via[(a, b)]`, not maintain its own `_SIGNAL_LAYER_TO_VIA` dict. See `jitx/references/architectural-patterns.md` § "Substrate-shaped tables live on the substrate" before adding per-layer constant tables. Also: instantiate generic substrates (`stackup = Generic_Stackup()`), don't inline-subclass them (`@inline class stackup(Generic_Stackup): pass`) — § "Instantiate, don't inline-subclass".

A "generic" substrate must be reusable across designs. Design-specific tags (`AntipadFenceTag` named after a particular escape design), design-specific trace widths (`DESKEW_TRACE_WIDTH`), or design-specific fence definitions do **not** belong in `generic_*.py` — push them into the consuming design. **Comments and docstrings are part of this surface too:** a generic substrate must not claim it's tuned for one downstream tool's extraction/export flow (`jitx-ansys` / HFSS, `odb++`) or state a fact the code doesn't back — that couples the reusable artifact to one consumer and asserts facts not in evidence. A neutral, evidenced mention of a tool isn't the problem; an unbacked tool-specific *suitability* claim is.

For a same-model self-critique pass on the substrate after writing (catches what these rules don't), invoke `jitx-code-review`. Optional for single-task use.

## File Structure

Everything goes in **one Python file** per substrate:

```python
# 1. Material definitions (Dielectric, Conductor subclasses)
# 2. Stackup class (Stackup or Symmetric)
# 3. FabricationConstraints class
# 4. Substrate class containing:
#    - stackup instance
#    - constraints instance
#    - Via nested classes (all types needed)
#    - RoutingStructure instances
#    - DifferentialRoutingStructure instances
```

## Source Documents

When the substrate comes from a source document — a fab's stackup report or quote, a laminate datasheet, a written spec — the document is **ground truth**: transcribe it, and trace every value in the substrate to a row, table cell, or field of the source. The only derived values are this skill's named engineering defaults (e.g. the reference-plane width default), each labeled at its call site and in the completeness check's Provenance row; anything else the source doesn't state is a question for the user, not an estimate. Parse the document and restate it to the user before writing code — layer count and construction, each dielectric's Dk/Df and their quoted frequency, foil weights and Rz, the via inventory, the impedance targets, the fab rules — and flag anything ambiguous; everything downstream is a transcription of that reading. Where the source disagrees with this skill's reference tables (a Dk, an Rz), the source wins. A format not listed below (a PDF report, an HFSS 3D Layout stackup XML) gets the same treatment: find the document's own structure, then map it onto the sections of this skill.

### Fab stackup report CSV

The JITX-recommended layout for a fab's impedance-controlled stackup report as CSV — the JumpStart kits ship one, and a fab's own export can be annotated into it. It is organized as `SECTION` blocks: `DOCUMENT` (quote metadata, board size, thickness totals, tolerances, finishes, the primary-units declaration), `REVISION_HISTORY`, `MATERIALS_DIELECTRIC`, `MATERIALS_COPPER`, `STACKUP`, `VIAS`, `IMPEDANCE`, `FAB_RULES`, `NOTES`. A differently shaped export gets mapped onto these concepts, not forced through this parsing. Conventions that matter:

- **Dual unit columns.** Dimensions carry `_mil` and `_mm` columns; the schema declares the mm values controlling where the two disagree (JITX is mm-native). Some rows populate only one column, so parse per cell, not per column.
- **`FAB_RULES` maps by the `JITX_attribute` column, not row order.** A row naming an attribute maps onto a mandatory `FabricationConstraints` field (see Fabrication Constraints for the full set). A row with an empty `JITX_attribute` is a capability limit (drill minimums, aspect-ratio ceilings, stacked-microvia counts, minimum dielectric between coppers): read it as written and check it by hand — some state an `N:1` string or a bare count, so they must not go through the same numeric parsing as the mappable rules. See "Capability limits and derived checks" under Fabrication Constraints.
- **`IMPEDANCE` quotes each controlled target once per geometry** — surface microstrip and inner stripline need different widths for the same impedance — with the modelled `eps_eff`, the loss, and a `Ref_layers` column naming that line's reference planes. A row with `Controlled = No` is the fab's default line/space: documentation, not a routing structure.
- **`NOTES` states the depth basis per drill type** (laser and mechanical depths are not measured the same way) — read it before deriving any aspect ratio.
- **`REVISION_HISTORY` is the re-issue signal.** On a revised report, re-derive everything that is arithmetic over a changed row (annular ring, aspect ratio) and re-run the capability hand-checks rather than carrying stale figures.

## Materials

Set properties as **class attributes**. Thickness goes on the class (fixed-thickness materials like copper foils) **or** is passed at instantiation (per-stackup dielectric thickness) — **never both**: `Material.__init__` raises `ValueError` if thickness is set as a class attribute and also passed to the constructor.

Soldermask is a `Dielectric` — define it like any other dielectric material:

```python
class SoldermaskLayer(Dielectric):
    """Soldermask — typically Er ≈ 3.8"""
    dielectric_coefficient = 3.8
    loss_tangent = 0.02
    # no thickness here — passed per-stackup at instantiation

class FR4_Prepreg(Dielectric):
    dielectric_coefficient = 4.4   # Dk (dielectric constant / relative permittivity)
    loss_tangent = 0.0168          # Df (dissipation factor)

class FR4_Core(Dielectric):
    dielectric_coefficient = 4.6   # Dk
    loss_tangent = 0.0168          # Df

class Copper1oz(Conductor):
    """RTF foil. Rz matte 6.0 µm / drum 3.5 µm — docstring is the durable record."""
    thickness = 0.035   # mm
    roughness = 0.0060  # mm (matte Rz ÷ 1000); field slated for deprecation — see below

class CopperHalfOz(Conductor):
    """HVLP-2 foil. Rz matte 2.0 µm / drum 0.7 µm."""
    thickness = 0.0175  # mm
    roughness = 0.0020  # mm (matte Rz ÷ 1000)
```

**`Conductor.roughness` is slated for deprecation — the durable home for roughness data is the material docstring.** Fab reports state roughness as Rz in **micrometres**, matte and drum side separately; record both sides in the docstring in the source's own units (the matte side faces the dielectric and dominates conductor loss) so simulation-side tools can consume them. On versions that still carry the field, you may also set the scalar (read as mm: matte-side Rz ÷ 1000) — but don't build logic on it, and never drop the source's roughness data just because the field is going away.

**Unit conversions — JITX is mm throughout; convert as you transcribe:**

- **mils → mm**: × 0.0254. When a source gives both units, follow the unit it declares controlling; absent a declaration, prefer the mm figure (JITX is mm-native) and use the other column as a cross-check.
- **Copper weight (oz) → mm**: prefer the source's **finished** thickness where it states one — outer layers gain panel plating past the nominal foil weight (1 oz ≈ 0.035 mm base foil), and `Conductor` has no copper-weight field, so the finished thickness is the number the stackup actually sums to. Record the nominal weight in the docstring.
- **Rz (µm) → mm**: ÷ 1000, matte side, if setting the deprecated `roughness` scalar (see above).

**Terminology:** `dielectric_coefficient` is the JITX attribute name for Dk (dielectric constant, also called relative permittivity or Er). `loss_tangent` is the JITX attribute name for Df (dissipation factor). Datasheets typically specify Dk and Df at a given frequency (e.g., 1 GHz or 10 GHz).

### Common Dielectric Materials

Reference table of common PCB dielectric materials. Values are typical at 10 GHz unless noted. Always confirm with the manufacturer's datasheet for your specific construction.

| Material | Manufacturer | Family | Dk | Df | Notes |
|----------|-------------|--------|----|----|-------|
| **Standard FR-4** |
| FR408HR | Isola | High-Tg epoxy | 3.68 | 0.0092 | Workhorse high-Tg FR-4; widely available |
| I-Speed | Isola | Low-loss epoxy | 3.64 | 0.0060 | Step down in loss vs standard FR-4 |
| N4000-13 EP | AGC/Nelco | High-speed epoxy | 3.60 | 0.0090 | High-speed digital backplanes |
| N7000-2HT | AGC/Nelco | High-speed laminate | 3.50 | 0.0090 | Dk/Df available at 2.5 and 10 GHz |
| **Low-Loss** |
| I-Tera MT40 | Isola | Very low-loss epoxy | 3.45 | 0.0031 | High-speed digital/RF |
| Megtron 6 | Panasonic | Low-loss multilayer | 3.34 | 0.0037 | Common in high-speed digital (at 13 GHz) |
| RO4350B | Rogers | Hydrocarbon/ceramic | 3.48 | 0.0037 | Popular RF laminate; FR-4 processable |
| RO4003C | Rogers | Hydrocarbon/ceramic | 3.38 | 0.0027 | Standard RF laminate |
| 25N | Arlon | Ceramic-filled woven glass | 3.38 | 0.0025 | Low loss with standard FR-4 processes |
| **Ultra-Low-Loss** |
| Astra MT77 | Isola | Ultra-low-loss | 3.00 | 0.0017 | RF/microwave and very-high-speed |
| Tachyon 100G | Isola | Ultra-low-loss | ~3.05 | ~0.0017 | Values vary by construction |
| Megtron 7 | Panasonic | Ultra-low-loss | varies | varies | Capture exact row for glass style/resin |
| **PTFE / RF** |
| RT/duroid 5880 | Rogers | Glass microfiber PTFE | 2.20 | 0.0009 | Ultra-low loss; microwave/RF |
| RT/duroid 5870 | Rogers | Glass microfiber PTFE | 2.33 | 0.0012 | Low Dk/loss; antennas/stripline |
| RO3003 | Rogers | Ceramic-filled PTFE | 3.00 | 0.0010 | Low loss PTFE; common RF choice |
| RO3035 | Rogers | Ceramic-filled PTFE | 3.50 | 0.0015 | PTFE with Dk ~3.5 |
| TLY-5A | Taconic/AGC | Low-loss PTFE | 2.17–2.40 | ~0.0009 | Selectable Dk range |
| TLX-0 | Taconic/AGC | Fiberglass PTFE | 2.45 | 0.0012 | Lowest Dk in TLX series |
| **High-Dk (miniaturization)** |
| RO3006 | Rogers | Ceramic-filled PTFE | 6.15 | 0.0020 | Higher Dk for size reduction |
| RO3010 | Rogers | Ceramic-filled PTFE | 10.20 | 0.0022 | High Dk for compact RF |
| CER-10 | Taconic/AGC | Organic-ceramic | 10.0 | 0.0035 | High Dk; check tolerances per lot |

### Copper Foil Types

Copper surface roughness affects insertion loss at high frequencies. Choose foil type based on your frequency range.

Rz values below are for the **matte/bonding side** (the side laminated to the dielectric core), which is the surface that dominates conductor loss. The drum/resist side is typically 2–5× smoother; use its Ra value when modelling the top surface of a trace.

| Copper Type | Rz — Matte/Bonding Side | Rz — Drum/Resist Side | Use Case |
|-------------|-------------------------|-----------------------|----------|
| Standard HTE (STD) | 5–10 μm | 3–5 μm | <1 GHz, general FR-4 inner layers |
| Reverse Treated Foil (RTF) | 5–10 μm | 3–5 μm | <5 GHz; adhesion treatment moves to drum side |
| Low Profile (LP / LoPro) | 2–4 μm | 1–2 μm | 1–10 GHz signal layers |
| Very Low Profile (VLP) | 2.5–5 μm | 1–2 μm | 5–25 Gbps; Megtron 6, Isola IS415/FR408HR |
| Hyper VLP (HVLP / SVLP) | 1–3 μm | 0.5–1 μm | 25–56 Gbps; high-speed SerDes |
| Ultra Low Profile (ULP) | 0.5–1.5 μm | 0.3–0.5 μm | >56 Gbps, mmWave (>24 GHz) |
| Rolled Annealed (RA) | 0.3–0.8 μm | 0.3–0.8 μm | RF/microwave, flex circuits; both sides smooth |

**Rule of thumb:** For signals above 5 GHz, use LP or smoother. Above 10 GHz, use VLP. For 25 Gbps+, use HVLP. For mmWave (>24 GHz) or >56 Gbps, use ULP or RA.

**Cannonball-Huray parameters** (for HFSS/EM simulation using the average HCPES+SCPES model):
- Nodule radius: `a = 0.0573 × Rz` (µm)
- Surface ratio: `Sr = 5.117` (constant, independent of foil type)
- Use matte-side roughness for the bottom surface of a trace; drum-side for the top. The matte side is the rougher of the two — typical Ra range 0.18–0.51 µm for standard foils; the drum side is much smoother — Ra ≈ Rz × 0.0573 (matches the Cannonball-Huray nodule-radius formula, ≈ 0.18 µm at Rz = 3.05 µm).

| Copper Type | Representative Rz (µm) | Nodule radius a (µm) |
|-------------|------------------------|----------------------|
| STD HTE | 8.0 | 0.458 |
| RTF | 6.0 | 0.344 |
| LP / LoPro | 3.0 | 0.172 |
| VLP | 3.5 | 0.201 |
| HVLP | 2.0 | 0.115 |
| ULP | 1.0 | 0.057 |
| RA | 0.5 | 0.029 |

## Stackup

### Choosing Symmetric vs explicit Stackup

`Symmetric` is for boards you are designing symmetric by construction. **When you are transcribing a source document that names both halves — a fab stackup report numbering L1..L20 with a function per layer — use the explicit `Stackup` instead**, even when the construction happens to be symmetric: `Symmetric`'s mirrored half is generated proxies that cannot carry the source's layer ids, so a design-side layer name no longer identifies a source row and row-by-row traceability breaks for half the board.

### Symmetric (boards symmetric by construction)

Define top half only — bottom auto-mirrors. **Last layer MUST be dielectric** (symmetry plane):

```python
class My4LayerStackup(Symmetric):
    soldermask = SoldermaskLayer(thickness=0.015)
    top = Copper1oz()
    prepreg = FR4_Prepreg(thickness=0.076)
    inner = CopperHalfOz()
    core = FR4_Core(thickness=1.265)  # center — MUST be dielectric
```

### Explicit Stackup (non-symmetric boards)

Top-to-bottom order. Named attributes or list. **Give copper layers informative names** describing their function (signal, ground, power) — these appear in the JITX UI and help users navigate the design:

```python
class My8LayerStackup(Stackup):
    top_mask = SoldermaskLayer(thickness=0.02)
    L8 = ThinCopper(name="L8-Patch")
    sub7 = Prepreg326(thickness=0.068)
    L7 = ThinCopper(name="L7-GND3")
    sub6 = Prepreg322(thickness=0.104)
    L6 = ThinCopper(name="L6-Signal")
    # ... all layers top to bottom ...
    L2 = ThinCopper(name="L2-GND1")
    sub1 = Prepreg325(thickness=0.068)
    L1 = ThickCopper(name="L1-Signal")
    bottom_mask = SoldermaskLayer(thickness=0.02)
```

### Inline Stackup (in Substrate class)

```python
class MySubstrate(Substrate):
    @inline
    class stackup(Symmetric):
        soldermask = SoldermaskLayer(thickness=0.015)
        top = Copper1oz()
        prepreg = FR4_Prepreg(thickness=0.076)
        inner = CopperHalfOz()
        core = FR4_Core(thickness=1.265)
```

## Via Types

Define as **nested classes** inside Substrate. All properties are `ClassVar`.

### Through-Hole (Standard)

```python
class THVia(Via):
    type = ViaType.MechanicalDrill
    start_layer = 0        # Side.Top also works
    stop_layer = -1        # Side.Bottom also works
    diameter = 0.45        # pad diameter (mm)
    hole_diameter = 0.3    # drill hole (mm)
```

### Through-Hole (Tented + Filled, Via-in-Pad)

```python
class THViaFilled(Via):
    type = ViaType.MechanicalDrill
    start_layer = Side.Top
    stop_layer = Side.Bottom
    diameter = 0.45
    hole_diameter = 0.3
    tented = True
    filled = True
    via_in_pad = True
```

### Laser Microvia (Single Span)

```python
class MicroVia_L1_L2(Via):
    type = ViaType.LaserDrill
    start_layer = 0
    stop_layer = 1
    diameter = 0.356
    hole_diameter = 0.178
    filled = True
    via_in_pad = True
```

**Code laser vias in drill direction**: `start_layer` is the surface the via is drilled from. A bottom-side microvia the source states as "from L20 to L19" is `start_layer = -1, stop_layer = -2` (negative indices count from the bottom) — not an ascending positive pair that reverses the entry surface.

### Stacked Microvia (Multi-Span Laser)

```python
class StackedVia_L1_L3(Via):
    type = ViaType.LaserDrill
    start_layer = 0
    stop_layer = 2
    diameter = 0.356
    hole_diameter = 0.178
    filled = True
    via_in_pad = True
```

### Buried Via (Internal Only)

```python
class BuriedVia_L3_L12(Via):
    type = ViaType.MechanicalDrill
    start_layer = 2
    stop_layer = 11
    diameter = 0.356
    hole_diameter = 0.178
    filled = True
```

### Backdrilled Via

Backdrill depth is set via `stop_layer` — set it to the target signal layer, then use `BackdrillSet` to remove the stub. The backdrill side is opposite to the signal entry:

```python
bd = Backdrill(
    diameter=0.5, startpad_diameter=0.7,
    solder_mask_opening=0.8, copper_clearance=0.6,
)

class BackdrilledVia_L3(Via):
    """Signal enters from top, connects at L3 — backdrill from bottom removes stub"""
    type = ViaType.MechanicalDrill
    start_layer = Side.Top
    stop_layer = 3             # target signal layer controls backdrill depth
    diameter = 0.6
    hole_diameter = 0.3
    filled = True
    via_in_pad = True
    backdrill = BackdrillSet(bottom=bd)  # backdrill from opposite side
```

**Dual backdrill (both sides) — incredibly uncommon, almost never needed:**

```python
    backdrill = BackdrillSet(
        top=Backdrill(diameter=0.5, startpad_diameter=0.7,
                      solder_mask_opening=0.8, copper_clearance=0.6),
        bottom=Backdrill(diameter=0.5, startpad_diameter=0.7,
                         solder_mask_opening=0.8, copper_clearance=0.6),
    )
```

### Per-Layer Pad Diameter (NFP Removal)

```python
class AdvancedVia(Via):
    type = ViaType.MechanicalDrill
    start_layer = 0
    stop_layer = -1
    diameter = 0.6
    hole_diameter = 0.3
    diameters = {
        0: 0.5,
        1: ViaDiameter(0.5, nfp=0.2),  # non-functional pad on layer 1
    }
```

### Via SI Models

```python
from jitx.si import PinModel

class ModeledVia(Via):
    # ... standard attributes ...
    models = {
        (0, -1): PinModel(5e-12, 0.05),  # top-to-bottom: 5ps delay, 0.05dB loss
        (0, 1): PinModel(2e-12, 0.02),
    }
```

Add `models=` only from simulated or measured data. A fab report contains no electrical models — with no `models=`, JITX inserts placeholders that correctly flag SI constraints until simulated ones exist. That is the right signal, not a gap to paper over with invented numbers.

## Routing Structures

### Single-Ended (RoutingStructure)

```python
RS_50 = RoutingStructure(
    impedance=50 * ohm,
    layers=symmetric_routing_layers({
        0: RoutingStructure.Layer(
            trace_width=0.12,       # mm
            clearance=0.2,          # mm
            velocity=phase_velocity((4.4 + 1) / 2),  # mm/s — microstrip effective Dk
            insertion_loss=0.018,   # dB/mm
        )
    }),
)
```

### Velocity Calculation

**When the source's impedance table quotes a modelled `eps_eff`, use it directly — `phase_velocity(eps_eff)`.** The fab's field-solved figure already includes what closed forms only approximate (a coated microstrip's eps_eff folds in the soldermask and varies with line width), so no single formula reproduces it. The formulas below are fallbacks for when no modelled figure exists:

```python
from jitxlib.physics import phase_velocity

vel_microstrip = phase_velocity((Dk + 1) / 2)   # microstrip effective Dk
vel_stripline = phase_velocity(Dk)               # stripline uses full Dk
vel_mixed = phase_velocity((Dk_pp + Dk_core) / 2)  # mixed dielectric
```

**`velocity` must be in mm/s, NOT m/s.** `phase_velocity()` returns mm/s. Passing a raw m/s value will be 1000x too small, producing wrong timing constraints.

```python
# WRONG — velocity in m/s (1000x too small, timing constraints will be wrong)
velocity = 1.5e8  # m/s — DO NOT USE

# CORRECT — always use phase_velocity() which returns mm/s
velocity = phase_velocity(4.2)  # returns ~1.46e11 mm/s
```

**Other routing-structure units:** `insertion_loss` is dB/mm; `pair_spacing` (differential) is the edge-to-edge gap between P and N.

### From a fab impedance table

One structure per controlled impedance target, with a layer entry for every layer/geometry the table lists — the same target needs a different width on surface microstrip than on inner stripline, keyed by conductor index (see the fab-CSV schema's `IMPEDANCE` conventions). Include neck-down and uncoupled-region entries where the table quotes them, and only there — never borrow a neck geometry from another row. Rows with no controlled target are documentation, not structures.

### symmetric_routing_layers()

Define top half only — mirrors to bottom using `-layer - 1` index:

```python
layers = symmetric_routing_layers({
    0: RoutingStructure.Layer(...),   # → also creates layer -1
    2: RoutingStructure.Layer(...),   # → also creates layer -3
})
```

### Layer with NeckDown

```python
RoutingStructure.Layer(
    trace_width=0.15, clearance=0.1,
    velocity=vel, insertion_loss=0.05,
    neck_down=RoutingStructure.NeckDown(
        trace_width=0.09, clearance=0.075,
    ),
)
```

### Layer with Via Fence

```python
RoutingStructure.Layer(
    trace_width=0.203, clearance=0.076,
    velocity=phase_velocity(1.99), insertion_loss=0.05,
).fence(
    MicroVia_L1_L2,   # via class
    ViaFencePattern(
        pitch=0.4,     # via-to-via spacing along route
        offset=0.43,   # trace center to via center
        num_rows=1,
    ),
    reference_layer=1, # ground reference for fence net
)
```

**Offset formula:** `offset = trace_width/2 + gap + via_pad_radius`

### Layer with Geometry and Reference

```python
RoutingStructure.Layer(
    trace_width=0.12, clearance=0.08,
    velocity=phase_velocity(3.26), insertion_loss=0.08,
)
.geometry(Soldermask, 0.25, side=Side.Top)        # soldermask opening
.geometry(KeepOut, 1.2, layers=LayerSet(1), pour=True)  # keepout on layer 1
.reference(2, 1.0)                                 # reference plane on layer 2
.fence(FenceViaClass, ViaFencePattern(pitch=0.5, offset=0.35, num_rows=1),
       reference_layer=2)
```

**Reference planes of unstated width:** source documents usually say *which* planes reference each line (a `Ref_layers` column) and never how wide they are. Carry the column — it is part of the impedance model, not decoration. For the width, use the skill's engineering default: **desired width = 3 × the dielectric thickness between the signal layer and that reference plane** (return current concentrates within a few dielectric heights of the trace; 3× captures it). Each plane gets 3× its *own* separation — a stripline's two references can differ — and a reference more than one dielectric away sums the dielectric thicknesses between. Label the value as the skill default at the point of use, and record it in the completeness check's Provenance row as `skill default (3× dielectric height)` — a named, rule-backed default is not an invented number, but an unlabeled one is:

**Label every call site.** Write the width as its derivation with the label on the same line — a section comment above the block is not enough; a reviewer reads the call site, and the summary's claim "labeled at every call site" must be literally true:

```python
# Ref planes L2 (above) and L4 (below), each across one 0.100 mm build-up:
.reference({1: 3 * 0.100, 3: 3 * 0.100})  # skill default: 3× dielectric height, not a source value
```

Do **not** pass `None` widths (`.reference(dict.fromkeys(...))`): construction accepts the mapping, but translation assigns `desired_width` straight into a protobuf float and **fails at build time** — a trap, not a fallback (verified against jitx 4.2.2 `_translate/routing.py`). If the user insists on strict source-only transcription with no defaults, record the `Ref_layers` identities in the docstring, omit `.reference()`, and name the omission as an open item in the completeness check. The scalar form `reference(layer)` without a width raises `TypeError: Must specify desired_width if layer is not a mapping`. Either way, never silently fill in a width nothing backs — the unlabeled invented number is exactly the failure the completeness check exists to catch.

### Differential Routing Structure

```python
DRS_100 = DifferentialRoutingStructure(
    name="100 Ohm Differential",
    impedance=100 * ohm,
    layers=symmetric_routing_layers({
        0: DifferentialRoutingStructure.Layer(
            trace_width=0.09,
            pair_spacing=0.137,    # edge-to-edge between P and N
            clearance=0.2,
            velocity=vel,
            insertion_loss=0.018,
        )
    }),
    uncoupled_region=RoutingStructure(
        name="50 Ohm SingleEnded, Uncoupled",
        impedance=50 * ohm,
        layers=symmetric_routing_layers({
            0: RoutingStructure.Layer(
                trace_width=0.09, clearance=0.2,
                velocity=vel, insertion_loss=0.018,
            )
        }),
    ),
)
```

**Differential with NeckDown (for BGA escape or constrained areas):**

```python
DRS_100_ND = DifferentialRoutingStructure(
    name="100 Ohm Differential w/ NeckDown",
    impedance=100 * ohm,
    layers=symmetric_routing_layers({
        0: DifferentialRoutingStructure.Layer(
            trace_width=0.09,
            pair_spacing=0.137,
            clearance=0.2,
            velocity=vel,
            insertion_loss=0.018,
            neck_down=DifferentialRoutingStructure.NeckDown(
                trace_width=0.075,
                pair_spacing=0.1,
                clearance=0.15,
            ),
        )
    }),
    uncoupled_region=RoutingStructure(
        name="100 Ohm Differential w/ NeckDown, Uncoupled",
        impedance=50 * ohm,  # half of differential impedance
        layers=symmetric_routing_layers({
            0: RoutingStructure.Layer(
                trace_width=0.09, clearance=0.2,
                velocity=vel, insertion_loss=0.018,
                neck_down=RoutingStructure.NeckDown(
                    trace_width=0.075, clearance=0.15,
                ),
            )
        }),
    ),
)
```

**Multi-layer differential (different trace widths per layer):**

```python
DRS_82 = DifferentialRoutingStructure(
    impedance=82 * ohm,
    layers=symmetric_routing_layers({
        0: DifferentialRoutingStructure.Layer(
            trace_width=0.154, pair_spacing=0.2,
            clearance=0.23, velocity=VEL, insertion_loss=0.018,
        ),
        2: DifferentialRoutingStructure.Layer(
            trace_width=0.137, pair_spacing=0.15,
            clearance=0.21, velocity=VEL, insertion_loss=0.018,
        ),
    }),
    uncoupled_region=RoutingStructure(
        impedance=41 * ohm,
        layers=symmetric_routing_layers({
            0: RoutingStructure.Layer(trace_width=0.154, clearance=0.15,
                                      velocity=VEL, insertion_loss=0.018),
            2: RoutingStructure.Layer(trace_width=0.137, clearance=0.15,
                                      velocity=VEL, insertion_loss=0.018),
        }),
    ),
)
```

## Fabrication Constraints

All values in mm.

```python
class MyFabRules(FabricationConstraints):
    min_copper_width = 0.09           # minimum trace width
    min_copper_copper_space = 0.09    # minimum copper spacing
    min_copper_hole_space = 0.254     # copper-to-hole spacing
    min_copper_edge_space = 0.3       # copper-to-board-edge
    min_annular_ring = 0.13           # via annular ring
    min_drill_diameter = 0.3          # minimum drill hole
    min_hole_to_hole = 0.5            # hole-to-hole spacing
    min_pitch_leaded = 0.217          # leaded package pitch
    min_pitch_bga = 0.377             # BGA pitch
    max_board_width = 500
    max_board_height = 400
    min_silkscreen_width = 0.153
    min_silk_solder_mask_space = 0.15
    min_silkscreen_text_height = 1.0
    solder_mask_registration = 0.05
    min_soldermask_opening = 0.0
    min_soldermask_bridge = 0.08
    min_th_pad_expand_outer = 0.2
    min_pth_pin_solder_clearance = 0.0
```

Custom attributes are allowed for fab-house-specific rules that are genuinely numeric lengths in mm (not engine-enforced, but shaped like the 19 real fields). **Capability limits that are not mm lengths — `N:1` aspect-ratio ceilings, stacked-microvia counts, available-on-request options — go in the class docstring, not as class attributes**: an unenforced count sitting in the same namespace as engine-enforced floats reads as enforced when it isn't. `FabricationConstraints` declares exactly 19 fields; a missing mandatory field fails translation.

### Capability limits and derived checks

Capability limits are verified by review and by tests against the area they govern — drill and aspect-ratio limits against the via inventory, minimum-dielectric limits against the stackup — the engine does not enforce them. The two standard derivations:

- **Annular ring** = `(pad − hole) / 2`, checked against the source's minimum annular ring.
- **Aspect ratio** = drill depth ÷ finished hole diameter, **on the depth basis the source states for that drill type** — laser depths are typically the ablated dielectric span, mechanical depths the full drilled depth; one convention applied to both gives wrong ratios.

## Design Constraints (Tags)

This section defines the *rules* (`design_constraint(...)`) a tag triggers. Choosing
*which* layout objects to tag and why — fanout/escape tags on package escapes,
direct-connect on high-current pads, tagging a code-based `Route` — is covered in the
**jitx-physical-layout** subskill.

For net-to-net clearances and via stitching rules:

```python
from jitx.constraints import Tag, design_constraint

class RFSignalTag(Tag): pass
class GNDTag(Tag): pass

# Trace width for tagged nets (unary constraint — single tag)
self.rule1 = design_constraint(RFSignalTag(), priority=1).trace_width(0.102)

# Net-to-net clearance (binary constraint — two tags)
self.rule2 = design_constraint(RFSignalTag(), RFSignalTag()).clearance(1.05)
self.rule3 = design_constraint(RFSignalTag(), GNDTag()).clearance(0.15)
```

**Board-wide defaults belong on the Design class, not the substrate.** The four canonical defaults — trace width, copper clearance, thermal relief, wider power/ground — go in `self.rules` on the top-level Design via `UnaryDesignConstraint(IsTrace)` / `BinaryDesignConstraint(IsCopper, IsCopper)` / `UnaryDesignConstraint(IsPad)` / `UnaryDesignConstraint(PowerTag() | GroundTag(), priority=1)`. See `jitx/references/project-builder-flow.md` "Default design rules" for the full pattern. The substrate's `FabricationConstraints` are the fab-minimum floor; the Design rules are the production-friendly defaults that sit above the floor.

`design_constraint(...)` and `UnaryDesignConstraint(...)` / `BinaryDesignConstraint(...)` are equivalent — the lowercase form is a factory that returns the right subtype based on arity. Use either.

### Tag inheritance & proliferation

**Tags form a hierarchy through class inheritance, and a rule on a base tag applies to every subclass tag.** This is a first-class JITX feature, not a trick — a tag can subclass *another tag*, not just `Tag`:

```python
class FenceTag(Tag): pass
class AntipadFenceTag(FenceTag): pass        # subclass of FenceTag
class DeskewAntipadFenceTag(AntipadFenceTag): pass   # subclass of AntipadFenceTag

# Applies to ALL fence tags — antipad, deskew, and any future FenceTag subclass:
self.fence_clearance = design_constraint(FenceTag(), GNDTag()).clearance(0.15)

# Applies only to the deskew variant; give it higher priority to override the base
# rule where they overlap (higher priority wins when multiple rules match):
self.deskew_fence = design_constraint(DeskewAntipadFenceTag(), priority=10).fence_via(...)
```

A net/pour/object tagged `DeskewAntipadFenceTag()` matches rules written against `DeskewAntipadFenceTag`, `AntipadFenceTag`, **and** `FenceTag`. Where two matching rules conflict, the higher `priority=` wins — that's how a specific subtag rule overrides the general base-tag rule. (Tags also combine with `&` / `|` / `~` and `Tag.any(...)` when a hierarchy isn't the right shape.)

**Flat tag proliferation is a smell.** A row of near-identical sibling tags that all inherit straight from `Tag` and differ only by name — each wired to its own rule that mostly restates the others — usually wants one of:
- a **base tag** carrying the shared rule, with subtags only where behavior actually differs (the neckdown case: one clearance rule for *all* neckdown via `NeckDownTag`, plus a higher-priority rule for the one neckdown level that's special), or
- a single **combined rule** (`design_constraint(TagA() | TagB())…`) when the tags aren't really distinct concepts.

Reach for many flat tags only when the rule sets are genuinely distinct. Mapping a spreadsheet of per-combination rules into a flat tag-per-row table is the usual way this goes wrong — the hierarchy expresses the same intent with far fewer rules.

### Conditions toolbox — builtin tags, layers, expressions

Rule conditions are not limited to tags you define:

- **Builtin tags** — `IsCopper`, `IsTrace`, `IsPour`, `IsVia`, `IsPad`,
  `IsBoardEdge`, `IsThroughHole`, `IsNeckdown`, `IsHole` (import from
  `jitx.constraints` or top-level `jitx`). The engine matches them by object
  kind; they are **conditions only** — `assign()` on a builtin raises
  `TypeError`. The four canonical Design defaults use these
  (see `jitx/references/project-builder-flow.md` "Default design rules").
- **`OnLayer(index)`** — layer-scoped condition (import from `jitx.constraints`;
  not re-exported top-level). `OnLayer.external()` matches the top and bottom
  copper layers; `OnLayer.internal()` is its inverse.
- **`AnyObject`** — matches everything; useful as the second condition of a
  binary rule.
- **Expressions** — conditions combine with `&` / `|` / `~`, and n-ary
  `Tag.any(*tags)` / `Tag.all(*tags)`.

```python
from jitx.constraints import design_constraint, AnyObject, OnLayer

# Wider high-speed traces on external layers only:
self.hs_outer = design_constraint(HighSpeedTag() & OnLayer.external()).trace_width(0.15)

# Keep everything 0.3 mm away from tagged power copper:
self.pwr_keepaway = design_constraint(PowerTag(), AnyObject).clearance(0.3)
```

Which objects can carry a tag (`Net`, `TopologyNet`, `Copper`, `Pour`, `Route`,
`Component`, `Circuit`, `Landpattern`, `Pad`, `Via`, `ControlPoint`), container
inheritance (tagging a landpattern tags its pads), and tagging `self` to tag all
instances of a class are covered in **jitx-physical-layout** "Layout-intent tags".

### Constraint effects — the full surface

A rule's effects are chainable methods; one rule can set several. The arity
boundary: unary rules (one condition) chain any effect below *except*
clearance; binary rules (two conditions) support only `.clearance()`.
Everything a `design_constraint(...)` can do (all dimensions in mm):

| Effect | Signature | Notes |
|---|---|---|
| Trace width | `.trace_width(width)` | example above |
| Clearance | `.clearance(clearance)` | **binary rules only** — `design_constraint(cond1, cond2)` |
| Via fencing | `.fence_via(via_cls, ViaFencePattern(...))` | along traces/pour outlines — see "Fenced Pour Outlines" below |
| Via stitching | `.stitch_via(via_cls, grid)` | grid = `SquareViaStitchGrid(pitch=, inset=)` or `TriangularViaStitchGrid(pitch=, inset=)`; `inset` = boundary-to-outermost-via-center distance |
| Thermal relief | `.thermal_relief(gap_distance, spoke_width, num_spokes)` | pad-to-pour connections |
| Serpentine params | `.serpentine_params(min_radius=, min_pitch=)` | bend radius / segment pitch of length-matching serpentines |
| Coupled-pair params | `.coupled_pair_params(deskew_bump_radius=, skew_tolerance=, min_bump_spacing=, max_bump_length=, long_lookahead=)` | deskew-bump geometry for diff pairs; `skew_tolerance` is in **mm** (distance, not time — the time-domain skew budget lives in `jitx-interconnect-constraints`) |
| Pour feature size | `.pour_feature_size(min_width)` | clips pour regions not coverable by a circle of `min_width` diameter fully inside the pour (sliver removal; thermal-relief spokes excluded) |
| Routing structure | `.routing_structure(rs, ...)` | see below |

```python
from jitx.constraints import design_constraint, SquareViaStitchGrid, IsPour

# Stitch tagged ground pours on a 2 mm square grid:
self.gnd_stitch = design_constraint(GNDPourTag()).stitch_via(
    GndVia, SquareViaStitchGrid(pitch=2.0, inset=0.5)
)

# Board-wide pour sliver removal:
self.no_slivers = design_constraint(IsPour).pour_feature_size(min_width=0.3)
```

### Routing structures as a rule effect

`.routing_structure(...)` assigns an impedance-controlled structure (defined on
this substrate) to every trace matching the condition — including plain 

…(truncated)
