---
title: "KiCad Netlist to Test Points: Rail Validation"
description: Export a KiCad netlist with kicad-cli, find power rails and test points in Python, derive voltage limits from the design, and emit a rail validation sequence.
url: https://galoislabs.ai/blog/kicad-netlist-test-points
author: Alex Hernandez
author_url: https://galoislabs.ai/blog/authors/alex-hernandez
published: "2026-08-15"
topic: Design to test
publisher: Galois Labs
---

# KiCad netlist to test points: generate a rail validation sequence in Python

![A bare circuit board on standoffs in isometric, three spring probes hanging just above three of its test pads.](https://galoislabs.ai/blog/figures/board-1.light.webp)

*FIG. 1 — BOARD, TEST POINTS, PROBES*

To turn a KiCad schematic into a rail validation sequence, export an XML netlist with `kicad-cli sch export netlist --format kicadxml`, parse it with Python's standard library, find the power nets and the test points on them, attach an expected voltage and tolerance to each rail, and write an ordered list of measurements with limits.

This guide builds that pipeline in three short Python modules and a bench runner. Everything comes from the netlist and a few symbol fields, so it runs in CI on every schematic change. The commands follow the [KiCad 10 command-line reference](https://docs.kicad.org/10.0/en/cli/cli.html); 10.0.6 is the [current stable release](https://www.kicad.org/download/linux/). KiCad 9 works too, and the two differences that matter here are called out below. The code needs Python 3.10 or later, and PyVISA for the runner. [A later section](https://galoislabs.ai/blog/kicad-netlist-test-points#how-do-i-run-this-rail-validation-in-galois-with-évariste) does the same work in Galois with Évariste, the agent in the Galois platform.

## How do I export a KiCad netlist from the command line?

`kicad-cli` ships with KiCad. Run the electrical rules check first, because a netlist from a schematic with a dangling wire or an unconnected power pin describes a different board than the one you think you have:

```sh title="export.sh"
set -euo pipefail
mkdir -p build

# Exit code 5 if the schematic has ERC violations; stop before trusting the netlist
kicad-cli sch erc --exit-code-violations -o build/erc.rpt board.kicad_sch

# XML netlist: readable with Python's standard library
kicad-cli sch export netlist --format kicadxml -o build/board.xml board.kicad_sch

# KiCad 10 only: export one assembly variant
kicad-cli sch export netlist --format kicadxml --variant PROTO -o build/board-proto.xml board.kicad_sch
```

The input is the root `.kicad_sch` file; hierarchical sheets come along. The default format is `kicadsexpr`, KiCad's own S-expression netlist, written to a `.net` file. The other formats are `cadstar`, `orcadpcb2`, `spice`, `spicemodel`, `pads` and `allegro`. XML is the practical choice for scripting because `xml.etree.ElementTree` reads it with no extra packages.

Two behaviors are worth knowing. The netlist export [warns, but still writes a file](https://gitlab.com/kicad/code/kicad/-/blob/10.0/eeschema/eeschema_jobs_handler.cpp), when the schematic has annotation errors such as duplicate references. Treat that warning as a failure in automation; the CI script at the end of this guide does. And `--variant` exists only in KiCad 10; the [KiCad 9 reference](https://docs.kicad.org/9.0/en/cli/cli.html) has no such option.

## What is in a KiCad XML netlist?

The root element is `export version="E"`. Four children matter, all written by [KiCad's XML exporter](https://gitlab.com/kicad/code/kicad/-/blob/10.0/eeschema/netlist_exporters/netlist_exporter_xml.cpp):

| Element      | Holds                                                                                                             | Useful for                                                |
| ------------ | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `design`     | Source file, export date, tool version, sheets, title blocks                                                      | Provenance for the plan                                   |
| `components` | One `comp` per placed symbol: value, footprint, user `fields`, `libsource` (library and symbol), `property` flags | Test points, regulators, resistor values, your own fields |
| `libparts`   | Each library symbol's pins: number, name, electrical type                                                         | Pin names without per-node parsing                        |
| `nets`       | One `net` (code, name, net class) per net, with a `node` for every connected pin: ref, pin, pinfunction, pintype  | Rails, ground, and what connects to what                  |

A trimmed excerpt from a KiCad 10 export, for a test point carrying two user fields and the 3.3 V net it sits on:

```xml title="build/board.xml (excerpt)"
<export version="E">
  <components>
    <comp ref="TP3">
      <value>TestPoint</value>
      <footprint>TestPoint:TestPoint_Pad_D1.0mm</footprint>
      <fields>
        <field name="V_nom">3.3</field>
        <field name="V_tol">2%</field>
        <!-- Footprint, Datasheet, Description -->
      </fields>
      <libsource lib="Connector" part="TestPoint" description="test point"/>
      <property name="V_nom" value="3.3"/>
      <property name="V_tol" value="2%"/>
      <!-- sheetpath, tstamps, units -->
    </comp>
  </components>
  <nets>
    <net code="3" name="+3V3" class="Default">
      <node ref="TP3" pin="1" pinfunction="1_1" pintype="passive"/>
      <node ref="U2" pin="2" pinfunction="VO_2" pintype="power_out"/>
      <node ref="U3" pin="1" pinfunction="VIN_1" pintype="power_in"/>
    </net>
  </nets>
</export>
```

Five details from the exporter source shape the parser:

- **Power symbols are absent.** [Any reference starting with `#`](https://gitlab.com/kicad/code/kicad/-/blob/10.0/eeschema/netlist_exporters/netlist_exporter_base.cpp) is virtual, so power symbols (`#PWR`) and power flags (`#FLG`) appear in neither `components` nor the node lists. The net keeps their name: the stock `+3V3` symbol [creates a global label named +3V3](https://gitlab.com/kicad/libraries/kicad-symbols/-/blob/10.0.6/power.kicad_symdir/+3V3.kicad_sym).
- **`pintype` uses fixed names.** They are `input`, `output`, `bidirectional`, `tri_state`, `passive`, `free`, `unspecified`, `power_in`, `power_out`, `open_collector`, `open_emitter` and `no_connect` ([pin_type.h](https://gitlab.com/kicad/code/kicad/-/blob/10.0/common/pin_type.h)). A pin marked with a no-connect flag gets `+no_connect` appended.
- **DNP parts stay in.** The CLI's XML export does not filter, so symbols marked do-not-populate or excluded from the board still appear, flagged with an empty `property name="dnp"` or `property name="exclude_from_board"`. Drop them yourself, or a DNP resistor ends up in your divider math.
- **User fields appear twice**, once under `fields` and again as `property` elements with a `value` attribute. The parser reads `fields`.
- **`pinfunction` changed format.** KiCad 9 writes the pin name, `pinfunction="VO"`. KiCad 10 writes name and number, `pinfunction="VO_2"`, so a parser that compares `pinfunction` to `"FB"` silently finds nothing on a KiCad 10 netlist.

| Behavior                            | KiCad 9          | KiCad 10                      |
| ----------------------------------- | ---------------- | ----------------------------- |
| `--variant` on `sch export netlist` | Not available    | Selects the variant to export |
| `pinfunction` for pin VO, number 2  | `VO`             | `VO_2`                        |
| Unnamed pin                         | No `pinfunction` | No `pinfunction`              |

## How do I parse a KiCad netlist in Python?

The first module turns the XML into three small dataclasses and an index from each reference to the nets it touches. It strips the `_<number>` suffix so pin names compare the same on both versions, and it removes DNP and excluded-from-board parts before anything else sees them.

```python title="rails/netlist.py"
"""Read a KiCad XML netlist (kicad-cli ... --format kicadxml) into plain objects."""
import xml.etree.ElementTree as ET
from collections import defaultdict
from dataclasses import dataclass, field

FLAGS = {"dnp", "exclude_from_board", "exclude_from_bom", "exclude_from_pos_files"}


@dataclass
class Part:
    ref: str
    value: str
    footprint: str
    symbol: str             # "Library:Symbol" from <libsource>
    fields: dict[str, str]  # user fields, plus Footprint, Datasheet, Description
    flags: set[str]         # dnp, exclude_from_board, ...


@dataclass
class Pin:
    ref: str
    num: str
    name: str  # pin name, "" when the pin has none
    type: str  # power_in, power_out, passive, input, ...


@dataclass
class Net:
    name: str
    netclass: str
    pins: list[Pin] = field(default_factory=list)


@dataclass
class Board:
    parts: dict[str, Part]
    nets: dict[str, Net]
    touches: dict[str, set[str]]  # ref -> names of the nets it connects to


def _pin_name(node: ET.Element) -> str:
    # KiCad 9 writes pinfunction="FB"; KiCad 10 writes pinfunction="FB_3".
    function, suffix = node.get("pinfunction", ""), "_" + node.get("pin", "")
    return function.removesuffix(suffix)


def load(path: str) -> Board:
    root = ET.parse(path).getroot()
    if root.tag != "export":
        raise ValueError(f"{path} is not a KiCad XML netlist; export with --format kicadxml")

    parts: dict[str, Part] = {}
    for comp in root.iterfind("components/comp"):
        src = comp.find("libsource")
        parts[comp.get("ref")] = Part(
            ref=comp.get("ref"),
            value=comp.findtext("value", ""),
            footprint=comp.findtext("footprint", ""),
            symbol=f"{src.get('lib')}:{src.get('part')}" if src is not None else "",
            fields={f.get("name"): f.text or "" for f in comp.iterfind("fields/field")},
            flags={p.get("name") for p in comp.iterfind("property")} & FLAGS,
        )

    # The XML export keeps DNP and excluded-from-board symbols; the built board does not.
    absent = {ref for ref, part in parts.items() if part.flags & {"dnp", "exclude_from_board"}}

    nets: dict[str, Net] = {}
    touches: dict[str, set[str]] = defaultdict(set)
    for n in root.iterfind("nets/net"):
        net = Net(n.get("name"), n.get("class", ""))
        for node in n.iterfind("node"):
            if node.get("ref") in absent:
                continue
            pintype = node.get("pintype", "").removesuffix("+no_connect")
            net.pins.append(Pin(node.get("ref"), node.get("pin"), _pin_name(node), pintype))
            touches[node.get("ref")].add(net.name)
        nets[net.name] = net

    return Board({r: p for r, p in parts.items() if r not in absent}, nets, touches)
```

## How do I find power nets and test points in a KiCad netlist?

No single attribute marks a net as a power rail, so the classifier treats a net as a rail if any of four signals fires. Each catches something the others miss:

| Signal                                    | Catches                                                                                                                                                                                                  | Misses                                                 |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| A `power_out` pin on the net              | LDO outputs: the stock AMS1117-3.3 symbol [inherits a power_out VO pin from AP1117-15](https://gitlab.com/kicad/libraries/kicad-symbols/-/blob/10.0.6/Regulator_Linear.kicad_symdir/AP1117-15.kicad_sym) | Buck outputs, which reach the rail through an inductor |
| A resistor from the net to a feedback pin | Adjustable regulators, bucks included                                                                                                                                                                    | Fixed-output parts with internal dividers              |
| A voltage in the net name                 | `+3V3`, `1V8_PLL`, `+12V`, `VCC_5V0`                                                                                                                                                                     | `VBUS`, `VBAT`, `VIN`                                  |
| A net class your team assigns             | Whatever you class as power                                                                                                                                                                              | Nets nobody classed                                    |

Symbol libraries are not consistent about pin types. In KiCad's stock library, the [TPS54302's SW pin](https://gitlab.com/kicad/libraries/kicad-symbols/-/blob/10.0.6/Regulator_Switching.kicad_symdir/TPS54302.kicad_sym) is `power_out` while the [TPS62130's](https://gitlab.com/kicad/libraries/kicad-symbols/-/blob/10.0.6/Regulator_Switching.kicad_symdir/TPS62130.kicad_sym) is `output`, so a `power_out` pin can mark a switch node rather than a rail. The classifier ignores `power_out` pins named SW or LX. Ground nets are matched by name and kept out of the rail list.

Test points are easier. KiCad's stock [TestPoint symbol](https://gitlab.com/kicad/libraries/kicad-symbols/-/blob/10.0.6/Connector.kicad_symdir/TestPoint.kicad_sym) has the reference prefix `TP` and one passive pin, and KiCad's test point footprints live in the `TestPoint` footprint library. A part counts as a test point if any of the three match. When a rail has no test point, the classifier falls back to a capacitor pad on that net and records the gap, because a missing test point is a design review finding, not a script failure.

```python title="rails/rails.py (detection)"
"""Find power rails, a probe point on each, and the window each rail must land in."""
import re
from dataclasses import dataclass

from rails.netlist import Board, Net

GROUND = re.compile(r"(?:[ADPS]?GND\w*|VSS\w*|0V)", re.IGNORECASE)
VOLTS = re.compile(
    r"(?:^|[^A-Za-z0-9.])([+-]?)(\d+)(?:V(\d+)|(\.\d+)?V)(?![A-Za-z0-9])", re.IGNORECASE
)
POWER_CLASSES = {"Power", "PWR"}  # net classes your team assigns to rails
FEEDBACK_PINS = {"FB", "VFB"}     # extend with the pin names your regulators use
SWITCH_PINS = {"SW", "LX"}        # switch nodes that some symbols type as power_out
MULTIPLIER = {"": 1.0, "R": 1.0, "r": 1.0, "m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6}


@dataclass
class Rail:
    net: str
    nominal: float
    low: float
    high: float
    basis: str          # where the window came from, for the reviewer
    probe: str          # test point ref, or a fallback pin when there is none
    source: str | None  # regulator that drives the rail
    parent: str | None  # rail that feeds that regulator


def is_ground(name: str) -> bool:
    return GROUND.fullmatch(name.rsplit("/", 1)[-1]) is not None


def is_test_point(board: Board, ref: str) -> bool:
    part = board.parts[ref]
    return (ref.startswith("TP") or "TestPoint" in part.symbol
            or part.footprint.startswith("TestPoint:"))


def volts_in_name(name: str) -> float | None:
    """+3V3 -> 3.3, 1V8_PLL -> 1.8, +12V -> 12.0, VCC_5V0 -> 5.0, -5V -> -5.0"""
    m = VOLTS.search(name.rsplit("/", 1)[-1])
    if m is None:
        return None
    sign, whole, after_v, decimals = m.groups()
    volts = float(f"{whole}.{after_v}" if after_v else whole + (decimals or ""))
    return -volts if sign == "-" else volts


def ohms(value: str) -> float:
    """10k -> 10000, 4k7 -> 4700, 4.7k -> 4700, 100R -> 100, 1M -> 1e6"""
    m = re.match(r"\s*(\d+(?:\.\d+)?)([RrmkKM]?)(\d*)", value)
    if m is None:
        raise ValueError(f"cannot read a resistance from {value!r}")
    whole, mult, frac = m.groups()
    return float(f"{whole}.{frac}" if frac else whole) * MULTIPLIER[mult]


def fraction(text: str) -> float:
    return float(text.strip().removesuffix("%")) / 100


def window(nominal: float, tol: float) -> tuple[float, float]:
    a, b = nominal * (1 - tol), nominal * (1 + tol)
    return min(a, b), max(a, b)


def feedback_divider(board: Board, rail: Net) -> tuple[str, str, str] | None:
    """(regulator, R_top, R_bottom) when a resistor runs from this rail to a feedback pin."""
    for top in sorted({p.ref for p in rail.pins if p.ref.startswith("R")}):
        for name in sorted(board.touches[top] - {rail.name}):
            fb = board.nets[name]
            reg = next((p.ref for p in fb.pins if p.name.upper() in FEEDBACK_PINS), None)
            bottom = sorted({p.ref for p in fb.pins if p.ref.startswith("R") and p.ref != top
                             and any(is_ground(n) for n in board.touches[p.ref])})
            if reg and len(bottom) == 1:
                return reg, top, bottom[0]
    return None
```

## How does the script set each rail's voltage window?

The netlist knows topology, not intent. It cannot say that a net called `VBUS` should sit at 5 V, or what tolerance a regulator holds. The classifier takes the window from three sources, in order, and records which one it used:

1. **Fields on the test point.** Add `V_nom` and `V_tol` fields to the test point symbol in the schematic, for example `3.3` and `2%`. These are your own field names, not a KiCad convention, and they travel with the design under version control. Use them whenever the designer knows the answer.
2. **The feedback divider.** For an adjustable regulator, add `V_ref` and `V_ref_tol` fields to the regulator symbol from its datasheet. The script finds the two resistors on the feedback net, reads their values and `Tolerance` fields, and computes the worst case. A missing `V_ref_tol` or `Tolerance` field counts as 1%, so add them when your parts are looser.
3. **The net name.** `+1V8` means 1.8 V. With no better information, the script applies a default ±5% and says so in the plan, so a reviewer can see which limits are placeholders.

The divider case is worth doing properly, because tolerances stack. With Vout = Vref × (1 + R_top / R_bottom), the low extreme takes Vref at its minimum, R_top low and R_bottom high; the high extreme takes the opposite corners. For Vref = 0.8 V ±1%, R_top = 105 kΩ and R_bottom = 20 kΩ, both 1%, the nominal output is 5.000 V and the worst case is 4.868 V to 5.136 V: about −2.6% and +2.7%, not ±1%. A worst-case window is conservative, which suits bring-up. For production limits on large volumes, a root-sum-square combination gives a tighter, statistical window.

Then pull each limit inward by the meter's uncertainty on the range you measure with, taken from its accuracy table. That guard band keeps a reading near a limit from passing a rail that is actually out of tolerance. The plan generator takes it as `--guard` in volts.

```python title="rails/rails.py (windows)"
def divider_window(board: Board, reg: str, top: str, bottom: str):
    """Worst-case output from the regulator's V_ref field and its feedback divider."""
    fields = board.parts[reg].fields
    if "V_ref" not in fields:
        return None
    vref, vref_tol = float(fields["V_ref"]), fraction(fields.get("V_ref_tol", "1%"))
    rt, rb = board.parts[top], board.parts[bottom]
    r_top, r_bot = ohms(rt.value), ohms(rb.value)
    t_top = fraction(rt.fields.get("Tolerance", "1%"))
    t_bot = fraction(rb.fields.get("Tolerance", "1%"))

    nominal = vref * (1 + r_top / r_bot)
    low = vref * (1 - vref_tol) * (1 + r_top * (1 - t_top) / (r_bot * (1 + t_bot)))
    high = vref * (1 + vref_tol) * (1 + r_top * (1 + t_top) / (r_bot * (1 - t_bot)))
    return nominal, low, high, f"{reg} V_ref={vref}, {top}={rt.value}, {bottom}={rb.value}"


def find_rails(board: Board, default_tol: float = 0.05) -> list[Rail]:
    rails: list[Rail] = []
    for net in board.nets.values():
        if is_ground(net.name):
            continue
        drivers = sorted({p.ref for p in net.pins
                          if p.type == "power_out" and p.name.upper() not in SWITCH_PINS})
        divider = feedback_divider(board, net)
        named = volts_in_name(net.name)
        if not (drivers or divider or named is not None or net.netclass in POWER_CLASSES):
            continue

        source = drivers[0] if drivers else divider[0] if divider else None
        parent = next((n.name for n in board.nets.values()
                       if source and not is_ground(n.name)
                       and any(p.ref == source and p.type == "power_in" for p in n.pins)), None)

        tps = sorted(p.ref for p in net.pins if is_test_point(board, p.ref))
        probe = tps[0] if tps else next(
            (f"{p.ref}.{p.num} (no test point)" for p in net.pins if p.ref.startswith("C")),
            "none (no test point)")

        marked = next((t for t in tps if "V_nom" in board.parts[t].fields), None)
        if marked:  # 1. Explicit fields on a test point win.
            f = board.parts[marked].fields
            nominal, tol = float(f["V_nom"]), f.get("V_tol", f"{default_tol:.0%}")
            low, high = window(nominal, fraction(tol))
            basis = f"{marked} V_nom={f['V_nom']}, V_tol={tol}" + ("" if "V_tol" in f else " (default)")
        elif divider and (d := divider_window(board, *divider)):  # 2. Feedback divider.
            nominal, low, high, basis = d
        elif named is not None:  # 3. The voltage in the net name, default tolerance.
            nominal = named
            low, high = window(nominal, default_tol)
            basis = f"net name, default ±{default_tol:.0%}"
        else:
            nominal = low = high = float("nan")
            basis = "unknown: add V_nom to a test point"

        rails.append(Rail(net.name, nominal, low, high, basis, probe, source, parent))
    return rails
```

## How do I generate a rail validation sequence from the netlist?

A rail list is not yet a sequence. Order matters, and so does what happens when a step fails. The generator below emits three phases:

1. **Unpowered resistance checks**, each rail to a ground test point. A solder bridge to ground reads near zero ohms. Set the floor from the design's load; let readings settle, since bulk capacitance charges from the meter's test current.
2. **Current-limited power-up** on the input rail. It depends on every resistance check, so one shorted rail stops the board from being powered.
3. **DC voltage on each rail, parents first.** Each rail depends on the rail that feeds its regulator. If 5 V is out of window, the 3.3 V rail, whose LDO runs from 5 V, is reported as blocked rather than failed, and the report shows one problem instead of a cascade.

```python title="rails/plan.py"
"""Turn rails into an ordered, limit-checked validation plan (JSON)."""
import argparse
import json
import math

from rails.netlist import load
from rails.rails import Rail, find_rails, is_ground, is_test_point


def depth(rail: Rail, by_net: dict[str, Rail]) -> int:
    d = 0
    while rail.parent in by_net and d < len(by_net):
        rail, d = by_net[rail.parent], d + 1
    return d


def build(netlist: str, supply: str, current_limit: float, min_ohms: float, guard: float) -> dict:
    board = load(netlist)
    rails = find_rails(board)
    by_net = {r.net: r for r in rails}
    rails.sort(key=lambda r: (depth(r, by_net), r.net))
    if supply not in by_net:
        raise SystemExit(f"supply net {supply!r} is not a rail; found {sorted(by_net)}")

    ground_tps = sorted(p.ref for n in board.nets.values() if is_ground(n.name)
                        for p in n.pins if is_test_point(board, p.ref))
    ref = ground_tps[0] if ground_tps else "GND (no test point)"

    steps: list[dict] = []
    shorts = []
    for r in rails:  # 1. Unpowered: no rail shorted to ground.
        steps.append({"id": f"short:{r.net}", "kind": "resistance", "probe": r.probe, "ref": ref,
                      "low": min_ohms, "high": None, "depends_on": []})
        shorts.append(f"short:{r.net}")

    steps.append({"id": "power-on", "kind": "supply", "net": supply,  # 2. Current-limited.
                  "volts": by_net[supply].nominal, "current_limit_a": current_limit,
                  "depends_on": shorts})

    gaps = []
    for r in rails:  # 3. Each rail inside its window, parents before children.
        if math.isnan(r.nominal):
            gaps.append(f"{r.net}: {r.basis}")
            continue
        if "no test point" in r.probe:
            gaps.append(f"{r.net}: probe {r.probe}")
        after = [f"rail:{r.parent}"] if r.parent in by_net else ["power-on"]
        steps.append({"id": f"rail:{r.net}", "kind": "dc_volts", "probe": r.probe, "ref": ref,
                      "nominal": round(r.nominal, 4), "low": round(r.low + guard, 4),
                      "high": round(r.high - guard, 4), "basis": r.basis, "depends_on": after})

    return {"netlist": netlist, "steps": steps, "gaps": gaps}


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("netlist")
    ap.add_argument("--supply", required=True, help="net the bench supply drives, e.g. +12V")
    ap.add_argument("--current-limit", type=float, required=True, help="amps")
    ap.add_argument("--min-ohms", type=float, required=True, help="unpowered rail-to-ground floor")
    ap.add_argument("--guard", type=float, default=0.0, help="volts to pull each limit inward")
    a = ap.parse_args()
    plan = build(a.netlist, a.supply, a.current_limit, a.min_ohms, a.guard)
    print(json.dumps(plan, indent=2, ensure_ascii=False))
```

On a board with a 12 V input, a buck to 5 V set by a 105k/20k divider, an AMS1117-3.3 from 5 V, and a 1.8 V LDO from 3.3 V, running `python -m rails.plan build/board.xml --supply +12V --current-limit 0.5 --min-ohms 10 --guard 0.005` produces these voltage steps, with 5 mV of guard band already applied:

```json title="build/rail-plan.json (voltage steps and gaps, trimmed)"
{
  "steps": [
    { "id": "rail:+12V", "probe": "TP1", "ref": "TP5", "nominal": 12.0, "low": 11.405,
      "high": 12.595, "basis": "net name, default ±5%", "depends_on": ["power-on"] },
    { "id": "rail:+5V", "probe": "TP2", "ref": "TP5", "nominal": 5.0, "low": 4.8727,
      "high": 5.1307, "basis": "U1 V_ref=0.8, R1=105k, R2=20k", "depends_on": ["rail:+12V"] },
    { "id": "rail:+3V3", "probe": "TP3", "ref": "TP5", "nominal": 3.3, "low": 3.239,
      "high": 3.361, "basis": "TP3 V_nom=3.3, V_tol=2%", "depends_on": ["rail:+5V"] },
    { "id": "rail:+1V8", "probe": "C7.1 (no test point)", "ref": "TP5", "nominal": 1.8,
      "low": 1.715, "high": 1.885, "basis": "net name, default ±5%", "depends_on": ["rail:+3V3"] }
  ],
  "gaps": ["+1V8: probe C7.1 (no test point)"]
}
```

Every limit carries its basis. The 12 V and 1.8 V windows are placeholders from net names, and the 1.8 V rail has no test point. Both are findings for the schematic review, before the first board is built. For what a full rail plan covers beyond DC levels (ripple, load transients, sequencing and timing), see the [power rail validation plan](https://galoislabs.ai/blog/power-rail-validation-plan).

## How do I run the rail validation sequence on a bench?

The runner below walks the plan with an operator holding the probes and a SCPI multimeter taking readings. It sends `MEAS:RES?` and `MEAS:VOLT:DC?`, the measurement commands of the ohmmeter and DC voltmeter instrument classes in [SCPI-99](https://www.ivifoundation.org/downloads/SCPI/scpi-99.pdf); confirm both in your meter's programming guide. The supply step is a prompt, because supply commands vary by vendor.

```python title="run_plan.py"
"""Walk a rail plan: an operator places the probes, a SCPI DMM takes each reading."""
import json
import sys
import time

import pyvisa

plan_path, resource = sys.argv[1], sys.argv[2]
with open(plan_path) as f:
    plan = json.load(f)

rm = pyvisa.ResourceManager()
dmm = rm.open_resource(resource, read_termination="\n", write_termination="\n", timeout=10_000)
idn = dmm.query("*IDN?").strip()
serial = input("DUT serial: ").strip()

not_passed: set[str] = set()
with open(f"rails-{serial}.jsonl", "a") as log:
    for step in plan["steps"]:
        record = {"t": time.time(), "serial": serial, "dmm": idn, **step}
        if any(dep in not_passed for dep in step["depends_on"]):
            record["status"] = "blocked"
        elif step["kind"] == "supply":
            input(f"Set the {step['net']} supply to {step['volts']} V, "
                  f"limit {step['current_limit_a']} A, output on. Enter: ")
            record["status"] = "done"
        else:
            input(f"{step['id']}: red lead on {step['probe']}, black on {step['ref']}. Enter: ")
            command = "MEAS:RES?" if step["kind"] == "resistance" else "MEAS:VOLT:DC?"
            value = float(dmm.query(command))
            high = float("inf") if step["high"] is None else step["high"]
            record |= {"command": command, "value": value,
                       "status": "pass" if step["low"] <= value <= high else "fail"}
        if record["status"] not in ("pass", "done"):
            not_passed.add(step["id"])
        log.write(json.dumps(record) + "\n")
        print(f"{record['status']:>7}  {step['id']}  {record.get('value', '')}")

input("Turn the supply output off. Enter: ")
```

Each line of the log ties a reading to the DUT serial, the meter's full `*IDN?` string, the command sent, the limits and where those limits came from. For error-queue checks, timeouts and a session class that logs every command, the [SCPI automation guide](https://galoislabs.ai/blog/scpi-automation-python) builds the production version of this loop. To drive a programmable supply as well, wrap its commands in a driver; [declarative instrument drivers](https://galoislabs.ai/blog/declarative-instrument-drivers) covers writing those as data instead of classes.

## Where are the test points on the PCB?

The schematic netlist has no coordinates. For a bed-of-nails fixture, a flying probe, or a probing diagram for the technician, the positions come from the board file, and the obvious export has a trap.

> **Position files skip bare-pad test points**
>
> KiCad's stock bare-pad test point footprints, such as [TestPoint_Pad_D1.0mm](https://gitlab.com/kicad/libraries/kicad-footprints/-/blob/10.0.6/TestPoint.pretty/TestPoint_Pad_D1.0mm.kicad_mod), carry the exclude-from-position-files and exclude-from-BOM attributes. `kicad-cli pcb export pos` leaves them out. Loop test points, which are real parts, stay in.

Use the IPC-D-356 export instead: `kicad-cli pcb export ipcd356 -o build/board.d356 board.kicad_pcb`. KiCad's [IPC-D-356 writer](https://gitlab.com/kicad/code/kicad/-/blob/10.0.6/pcbnew/exporters/export_d356.cpp) emits a record for every copper pad and via, with net name, reference, pin, position and which side it can be probed from, regardless of footprint attributes. It uppercases net names and shortens any longer than 14 characters, so join its records to the plan on reference and pin, not net name, and each step gets an X/Y location.

## How do I keep the test plan in sync with the schematic?

Generate the plan in CI on every schematic change and compare it with the reviewed copy. A renamed rail, a changed divider or a deleted test point then shows up as a diff in the pull request, next to the schematic change that caused it:

```sh title="ci/rails.sh"
set -euo pipefail
mkdir -p build
kicad-cli sch erc --exit-code-violations -o build/erc.rpt hw/board.kicad_sch
kicad-cli sch export netlist --format kicadxml -o build/board.xml hw/board.kicad_sch 2>&1 | tee build/netlist.log
if grep -q "annotation errors" build/netlist.log; then exit 1; fi   # warned, but the file was written
python -m rails.plan build/board.xml --supply +12V --current-limit 0.5 \
  --min-ohms 10 --guard 0.005 > build/rail-plan.json
jq -e '.gaps == []' build/rail-plan.json > /dev/null   # fail on rails without probes or limits
git diff --no-index --exit-code test/rail-plan.json build/rail-plan.json
```

The reviewed plan is a test artifact like any other. When the diff is intended, the engineer who changed the schematic updates `test/rail-plan.json` in the same pull request and a second engineer approves both. For running the bench steps themselves from CI, see [hardware tests in CI](https://galoislabs.ai/blog/hardware-tests-in-ci); for the rest of a first power-on, the [board bring-up checklist](https://galoislabs.ai/blog/board-bring-up-checklist).

## How do I run this rail validation in Galois with Évariste?

Open Évariste from the app sidebar (Ctrl+Shift+E) beside the project; it reaches the bench through galois-edge. The rails and windows still come from the design, here the guarded windows and their bases in `build/rail-plan.json`. A sequence runs end to end on its own, so the one addition to the code path's bench is a scanner: wire each probe point and TP5 to a channel, by hand or through a fixture placed from the IPC-D-356 positions above. Then ask:

> Create two sequences with the bench supply, DMM and scanner. Unpowered: resistance from each probe point to TP5, at least 10 Ω, letting each reading settle: +12V at TP1, +5V at TP2, +3V3 at TP3, +1V8 at C7.1. Powered: set a 0.5 A current limit and 12 V, output on, wait for the rails to settle, then DC volts to TP5, parents first: +12V 11.405 to 12.595 V (net name, default ±5%); +5V 4.8727 to 5.1307 V (U1 V_ref=0.8, R1=105k, R2=20k); +3V3 3.239 to 3.361 V (TP3 V_nom=3.3, V_tol=2%); +1V8 1.715 to 1.885 V (net name, default ±5%). Name each step with its rail, probe point and limit basis. End with the output off.

Splitting the work into two sequences keeps the gate that `plan.py` writes into `depends_on`: the board is powered only after you have seen every rail clear its floor. A single sequence can also hold that gate as Condition steps on the resistance readings, as in [the reviewed sequence](https://galoislabs.ai/blog/review-ai-generated-test-plan#how-galois-handles-ai-drafted-test-sequences); two sequences keep the decision to power the board with you.

Évariste lists the instruments on your team's edges and reads their profile commands. If one has no profile yet, upload its programming manual; Évariste generates a profile, and after you review it, deploys it to the edge and binds it to the instrument. Both sequences land as drafts; the powered one, abridged:

```yaml title="rails_powered.yaml (excerpt)"
name: "Rail validation, 12 V input"
steps:
  - name: "Current limit 0.5 A"
    type: action
    config:
      instrument_id: "psu"
      command_name: "source_current"
      parameters: { value: "0.5" }

  # source_voltage 12.0, output_on, a settle wait and the +12V step follow;
  # scanner steps that route each probe point to the meter are elided

  - name: "+5V at TP2 (U1 V_ref=0.8, R1=105k, R2=20k)"
    type: numeric_limit
    config:
      instrument_id: "dmm"
      command_name: "measure_voltage_dc"
      low_limit: 4.8727
      high_limit: 5.1307
      unit: "V"
      comparison: "GELE"

  - name: "+3V3 at TP3 (TP3 V_nom=3.3, V_tol=2%)"
    type: numeric_limit
    config:
      instrument_id: "dmm"
      command_name: "measure_voltage_dc"
      low_limit: 3.239
      high_limit: 3.361
      unit: "V"
      comparison: "GELE"

  # +1V8 at C7.1, then output_off
```

**Review and approval.** Neither draft can run until an engineer approves it. Check each window and its basis against `rail-plan.json`, the 10 Ω floor (`low_limit: 10`, no high limit), the scanner channel behind each probe point, the current limit set before the output turns on, and the final output off; +1V8 still probes C7.1, the open gap. A failed step is recorded and the sequence carries on, so on the powered run the 0.5 A limit is what protects a board with a fault the resistance checks missed; confirm it before approving. [How to review an AI-generated test plan](https://galoislabs.ai/blog/review-ai-generated-test-plan) covers the rest. Edits, in conversation or the sequence builder, become new versions with diffs and need approval again; a settled version can be production-locked.

**Run.** Start the unpowered sequence with the board's serial. If every rail clears 10 Ω, start the powered one. Both run on the bench through galois-edge, and Monitor shows the channels live.

**Results and interpretation.** Each step records its measured value, limits, pass or fail, raw command and response, instrument, operator, DUT serial and timestamps. Because every rail step runs, a rail that `run_plan.py` would mark blocked still has a reading. Ask Évariste which failures sit downstream of a failed parent, which rails passed close to a limit, or how this board compares with the last; answers cite runs and notes.

**Report.** Ask for a report on each run. "Generate a test report from the last run" produces a PDF or HTML report from a LaTeX template, shareable to Slack; add the schematic revision the windows came from in the report editor.

| Step              | Code path (this guide)                       | Galois with Évariste                                      |
| ----------------- | -------------------------------------------- | --------------------------------------------------------- |
| Rails and windows | `netlist.py`, `rails.py`, `plan.py`          | Same windows and bases, stated in the prompt              |
| Sequence          | Ordered JSON plan with `depends_on`          | Two drafts; the split is the power gate                   |
| Instruments       | PyVISA, SCPI-99 `MEAS` queries               | Library or generated profiles                             |
| Bench             | Operator sets the supply and moves the leads | Supply profile commands; probe points on scanner channels |
| Review            | Reviewed `test/rail-plan.json`               | Draft approval; versioned edits                           |
| Run               | `python run_plan.py`                         | galois-edge, watched in Monitor                           |
| Record            | `rails-<serial>.jsonl`                       | Per-step record with raw I/O                              |
| Interpret         | Read the log                                 | Failures, near-limit passes, comparisons                  |
| Report            | Your own script                              | Generated PDF or HTML                                     |
| Keep in sync      | CI diff of the plan                          | Same CI diff; the sequence edit is a new version          |

You no longer write or maintain the bench side: `run_plan.py` with its PyVISA session, operator prompts, limit checks and JSONL log, a driver for the supply, or a report script. The netlist modules can keep producing the windows and the CI diff. The windows, the review and approval of each draft, the probe wiring and bench safety stay yours; [AI test automation for hardware benches](https://galoislabs.ai/blog/ai-test-automation-hardware) covers the full loop.

## Where Galois fits

Galois is agent-driven test engineering for hardware teams: agents generate tests and instrument drivers, run them on real benches through the open-source galois-edge daemon, and turn the results into reports and a shared engineering record.

A plan like this one maps onto a [Galois sequence](https://galoislabs.ai/product): steps with limits, run against instruments the daemon has found. A sequence Évariste drafts cannot run until an engineer approves it, and an approved sequence that is edited must be approved again. Every run records the SCPI sent, the raw response, the measured value, its limits and the instrument for each step, with the operator and DUT serial on the run. The design side is in build: Galois KiCad and Altium plugins, with the direction that the design itself, rather than an exported netlist, feeds sequence generation. Everything above works without them.

Whoever writes the plan, a person or an agent, the review is the same: check every limit's basis and every gap. [Reviewing an AI-generated test plan](https://galoislabs.ai/blog/review-ai-generated-test-plan) covers what to look for, and [schematic to test plan](https://galoislabs.ai/blog/schematic-to-test-plan) widens the scope from rails to the whole board. If the meter is plugged into another machine running galois-edge, the `pyvisa-galois` backend reaches it with a one-line change to `pyvisa.ResourceManager("@galois")` ([PyVISA backend docs](https://docs.galoislabs.ai/guides/pyvisa/)), and the [Galois and PyVISA comparison](https://galoislabs.ai/compare/pyvisa) shows where each fits. To hand the runner itself to Galois, [the Évariste walkthrough above](https://galoislabs.ai/blog/kicad-netlist-test-points#how-do-i-run-this-rail-validation-in-galois-with-évariste) runs the same plan.

## Frequently asked questions

### How do I export a netlist from KiCad on the command line?

Run kicad-cli sch export netlist --format kicadxml -o board.xml board.kicad_sch. Without --format, kicad-cli writes the default kicadsexpr format; the other options are cadstar, orcadpcb2, spice, spicemodel, pads and allegro. KiCad 10 adds --variant to export one assembly variant.

### Does a KiCad netlist include test point locations?

No. The schematic netlist holds parts, pins and nets, not coordinates. Coordinates come from the board: kicad-cli pcb export ipcd356 writes every copper pad and via with its net, reference, pin and position. The position file from kicad-cli pcb export pos skips footprints marked exclude from position files, which includes KiCad's stock bare-pad test points.

### Why are power symbols missing from the KiCad netlist?

KiCad treats any symbol whose reference starts with # as virtual. Power symbols (#PWR) and power flags (#FLG) are left out of the components list and out of each net's node list. The net they name keeps its name, such as +3V3 or GND, so power rails are still identifiable by name.

### How do I set the tolerance for a power rail test?

Start from the regulator. For a fixed regulator, use the output accuracy from its datasheet. For an adjustable one, combine the reference voltage tolerance with the feedback resistor tolerances as a worst case: a 0.8 V reference at 1% with a 105k/20k divider of 1% resistors gives 4.868 V to 5.136 V around a 5 V nominal, about -2.6% to +2.7%. Then pull each limit inward by your meter's uncertainty on that range.

### Can Galois run a KiCad rail plan on the bench without a Python runner?

Yes. Give Évariste, the agent in the Galois platform, each rail's probe point, window and limit basis from rail-plan.json, such as +5V at TP2 between 4.8727 V and 5.1307 V, plus the 12 V supply's 0.5 A current limit, and it drafts versioned sequences for the bench supply, the DMM and a scanner wired to the probe points. They stay drafts until an engineer reviews and approves them; then they run on the bench through galois-edge, and Évariste can read the results and generate a report.
