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:
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
# 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:
# 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
_miland_mmcolumns; 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_RULESmaps by theJITX_attributecolumn, not row order. A row naming an attribute maps onto a mandatoryFabricationConstraintsfield (see Fabrication Constraints for the full set). A row with an emptyJITX_attributeis 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 anN:1string 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.IMPEDANCEquotes each controlled target once per geometry — surface microstrip and inner stripline need different widths for the same impedance — with the modelledeps_eff, the loss, and aRef_layerscolumn naming that line's reference planes. A row withControlled = Nois the fab's default line/space: documentation, not a routing structure.NOTESstates the depth basis per drill type (laser and mechanical depths are not measured the same way) — read it before deriving any aspect ratio.REVISION_HISTORYis 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:
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
Conductorhas 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
roughnessscalar (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):
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:
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)
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)
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)
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)
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)
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)
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:
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:
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)
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
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)
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:
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.
# 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:
layers = symmetric_routing_layers({
0: RoutingStructure.Layer(...), # → also creates layer -1
2: RoutingStructure.Layer(...), # → also creates layer -3
})
Layer with NeckDown
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
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
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:
# 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
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):
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):
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.
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:
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:
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 fromjitx.constraintsor top-leveljitx). The engine matches them by object kind; they are conditions only —assign()on a builtin raisesTypeError. The four canonical Design defaults use these (seejitx/references/project-builder-flow.md"Default design rules"). OnLayer(index)— layer-scoped condition (import fromjitx.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-aryTag.any(*tags)/Tag.all(*tags).
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 |
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)