Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
97c6f2bbeThis file is pinned to the selected release. Download the original to open it in your design tool.
"""Tests for the electrical-rule check (ERC) (SPEC §3.6).
Unit-tested with synthetic ``Schematic``/``Net``/``Component``/``Pin`` objects
covering every rule, plus a smoke run against committed ``*.SchDoc`` fixtures
read through the frozen Altium reader. Assertion style mirrors test_bom.py.
"""
from __future__ import annotations
import os
import pytest
from akcli.checks import erc
from akcli.config import Config
from akcli.model import Component, Net, Pin, PinType, Schematic
from akcli.readers import altium_sch
from akcli.report import Finding, Severity
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _pin(
number: str,
*,
name: str | None = None,
etype: PinType = PinType.PASSIVE,
x: float = 0.0,
y: float = 0.0,
) -> Pin:
return Pin(number=number, name=name, x_mil=x, y_mil=y, electrical_type=etype)
def _comp(
designator: str,
pins: list[Pin],
*,
undesignated: bool = False,
lib: str = "Device:U",
value: str | None = None,
dnp: bool = False,
) -> Component:
return Component(
designator=designator,
library_ref=lib,
x_mil=0.0,
y_mil=0.0,
pins=pins,
undesignated=undesignated,
value=value,
dnp=dnp,
)
def _net(name, members, *, aliases=None, source_names=None) -> Net:
return Net(
name=name,
members=sorted(members),
aliases=list(aliases or []),
source_names=list(source_names or ([name] if name else [])),
is_named=name is not None,
)
def _sch(
components: list[Component],
nets: list[Net],
*,
no_erc_points=None,
source_format: str = "altium",
) -> Schematic:
return Schematic(
source_path="<test>",
source_format=source_format,
components=components,
nets=nets,
no_erc_points=list(no_erc_points or []),
)
def _cfg(*, rails=None, waivers=None) -> Config:
return Config(rails=list(rails or []), erc_waivers=list(waivers or []))
def _codes(findings: list[Finding]) -> list[str]:
return [f.code for f in findings]
def _by_code(findings: list[Finding], code: str) -> list[Finding]:
return [f for f in findings if f.code == code]
# ---------------------------------------------------------------------------
# return contract
# ---------------------------------------------------------------------------
def test_returns_list_of_findings():
sch = _sch([_comp("U1", [_pin("1")])], [_net("STAT", [("U1", "1")])])
out = erc.run(sch, _cfg())
assert isinstance(out, list)
assert all(isinstance(f, Finding) for f in out)
def test_runs_with_cfg_none():
sch = _sch([_comp("U1", [_pin("1")])], [_net(None, [("U1", "1")])])
assert isinstance(erc.run(sch, None), list)
# ---------------------------------------------------------------------------
# dangling single-pin net
# ---------------------------------------------------------------------------
def test_single_pin_net_is_dangling():
sch = _sch([_comp("U1", [_pin("1")])], [_net(None, [("U1", "1")])])
dang = _by_code(erc.run(sch, _cfg()), erc.ERC_DANGLING_NET)
assert len(dang) == 1
assert dang[0].refs == ["U1.1"]
assert dang[0].severity is Severity.WARNING
def test_multi_pin_net_is_not_dangling():
sch = _sch(
[_comp("U1", [_pin("1")]), _comp("U2", [_pin("1")])],
[_net("NET1", [("U1", "1"), ("U2", "1")])],
)
assert _by_code(erc.run(sch, _cfg()), erc.ERC_DANGLING_NET) == []
def test_no_connect_pin_is_not_dangling():
# An explicit NO_CONNECT pin is intentionally unconnected -> not flagged.
sch = _sch([_comp("U1", [_pin("1", etype=PinType.NO_CONNECT)])],
[_net(None, [("U1", "1")])])
assert _by_code(erc.run(sch, _cfg()), erc.ERC_DANGLING_NET) == []
# ---------------------------------------------------------------------------
# No-ERC suppression (geo-match the pin tip within grid tolerance)
# ---------------------------------------------------------------------------
def test_no_erc_marker_suppresses_dangling():
pin = _pin("1", x=1000.0, y=2000.0)
sch = _sch([_comp("U1", [pin])], [_net(None, [("U1", "1")])],
no_erc_points=[(1000.0, 2000.0)])
assert _by_code(erc.run(sch, _cfg()), erc.ERC_DANGLING_NET) == []
def test_no_erc_marker_elsewhere_does_not_suppress():
pin = _pin("1", x=1000.0, y=2000.0)
sch = _sch([_comp("U1", [pin])], [_net(None, [("U1", "1")])],
no_erc_points=[(5000.0, 9000.0)]) # far from the pin tip
assert len(_by_code(erc.run(sch, _cfg()), erc.ERC_DANGLING_NET)) == 1
# ---------------------------------------------------------------------------
# no-connect marker on a CONNECTED pin (NC_PIN_CONNECTED)
# ---------------------------------------------------------------------------
def test_nc_marker_on_connected_pin_is_flagged():
# U1.1 carries an NC marker yet sits on a 2-member net -> contradiction.
pin = _pin("1", x=1000.0, y=2000.0)
sch = _sch(
[_comp("U1", [pin]), _comp("R1", [_pin("1")])],
[_net("SIG_A", [("U1", "1"), ("R1", "1")])],
no_erc_points=[(1000.0, 2000.0)],
)
hits = _by_code(erc.run(sch, _cfg()), erc.NC_PIN_CONNECTED)
assert len(hits) == 1
assert hits[0].severity is Severity.WARNING
assert hits[0].refs[0] == "U1.1"
assert "SIG_A" in hits[0].message
def test_nc_marker_on_dangling_pin_not_flagged_as_connected():
# Single-member net: the marker is doing its intended job — suppressing
# the dangling warning — and must NOT trip NC_PIN_CONNECTED.
pin = _pin("1", x=1000.0, y=2000.0)
sch = _sch([_comp("U1", [pin])], [_net(None, [("U1", "1")])],
no_erc_points=[(1000.0, 2000.0)])
findings = erc.run(sch, _cfg())
assert _by_code(findings, erc.NC_PIN_CONNECTED) == []
assert _by_code(findings, erc.ERC_DANGLING_NET) == []
def test_nc_marker_elsewhere_does_not_flag_connected_pin():
# Marker far from any pin of the net -> no contradiction reported.
pin = _pin("1", x=1000.0, y=2000.0)
sch = _sch(
[_comp("U1", [pin]), _comp("R1", [_pin("1")])],
[_net("SIG_A", [("U1", "1"), ("R1", "1")])],
no_erc_points=[(5000.0, 9000.0)],
)
assert _by_code(erc.run(sch, _cfg()), erc.NC_PIN_CONNECTED) == []
def test_nc_pin_connected_waiver_by_net_suppresses():
pin = _pin("1", x=1000.0, y=2000.0)
sch = _sch(
[_comp("U1", [pin]), _comp("R1", [_pin("1")])],
[_net("SIG_A", [("U1", "1"), ("R1", "1")])],
no_erc_points=[(1000.0, 2000.0)],
)
cfg = _cfg(waivers=[{"net": "SIG_A", "rule": "nc_pin_connected",
"reason": "legacy marker kept on purpose"}])
assert _by_code(erc.run(sch, cfg), erc.NC_PIN_CONNECTED) == []
# ---------------------------------------------------------------------------
# driver conflict (TYPE-gated by confidence)
# ---------------------------------------------------------------------------
def test_driver_conflict_high_confidence_is_warning():
# Two OUTPUT pins on one net, all pins typed -> confidence 1.0 -> WARNING.
sch = _sch(
[
_comp("U1", [_pin("1", etype=PinType.OUTPUT)]),
_comp("U2", [_pin("1", etype=PinType.OUTPUT)]),
],
[_net("BUS", [("U1", "1"), ("U2", "1")])],
)
conf = _by_code(erc.run(sch, _cfg()), erc.ERC_DRIVER_CONFLICT)
assert len(conf) == 1
assert conf[0].severity is Severity.WARNING
assert {"U1.1", "U2.1"} <= set(conf[0].refs)
def test_driver_conflict_low_confidence_is_downgraded_to_note():
# Two OUTPUT pins drowned by many Passive pins -> confidence < 0.2, and the
# net itself carries an UNTYPED pin (R1.1) -> demoted to NOTE.
extra = _comp("R1", [_pin(str(i)) for i in range(1, 10)]) # 9 passive pins
sch = _sch(
[
_comp("U1", [_pin("1", etype=PinType.OUTPUT)]),
_comp("U2", [_pin("1", etype=PinType.OUTPUT)]),
extra,
],
[_net("BUS", [("U1", "1"), ("U2", "1"), ("R1", "1")])],
)
conf = _by_code(erc.run(sch, _cfg()), erc.ERC_DRIVER_CONFLICT)
assert len(conf) == 1
assert conf[0].severity is Severity.NOTE
assert "type-confidence low" in conf[0].message
def test_fully_typed_net_keeps_severity_on_low_confidence_board():
# Same low board confidence, but EVERY pin of this 3-member net is typed:
# the demotion's excuse (an untyped pin might be the missing driver) is
# gone, so the finding keeps full severity.
extra = _comp("R1", [_pin(str(i)) for i in range(1, 20)]) # 19 passive pins
sch = _sch(
[
_comp("U1", [_pin("1", etype=PinType.OUTPUT)]),
_comp("U2", [_pin("1", etype=PinType.OUTPUT)]),
_comp("U3", [_pin("1", etype=PinType.INPUT)]),
extra,
],
[_net("BUS", [("U1", "1"), ("U2", "1"), ("U3", "1")])],
)
conf, _typed, _total = erc._type_confidence(sch)
assert conf < erc._TYPE_CONFIDENCE_MIN # board-wide: low
hits = _by_code(erc.run(sch, _cfg()), erc.ERC_DRIVER_CONFLICT)
assert len(hits) == 1
assert hits[0].severity is Severity.WARNING
assert "type-confidence low" not in hits[0].message
def test_partially_typed_net_on_low_confidence_board_is_demoted():
# One untyped member is enough to restore the demotion (per-NET verdict).
extra = _comp("R1", [_pin(str(i)) for i in range(1, 10)])
sch = _sch(
[
_comp("U1", [_pin("1", etype=PinType.OUTPUT)]),
_comp("U2", [_pin("1", etype=PinType.OUTPUT)]),
_comp("R2", [_pin("1"), _pin("2")]),
extra,
],
[_net("BUS", [("U1", "1"), ("U2", "1"), ("R2", "1")])],
)
hits = _by_code(erc.run(sch, _cfg()), erc.ERC_DRIVER_CONFLICT)
assert len(hits) == 1 and hits[0].severity is Severity.NOTE
def test_two_bidirectional_pins_are_not_a_conflict():
# Bidirectional (bus) pins may legitimately share a net.
sch = _sch(
[
_comp("U1", [_pin("1", etype=PinType.BIDIRECTIONAL)]),
_comp("U2", [_pin("1", etype=PinType.BIDIRECTIONAL)]),
],
[_net("SDA", [("U1", "1"), ("U2", "1")])],
)
assert _by_code(erc.run(sch, _cfg()), erc.ERC_DRIVER_CONFLICT) == []
def test_driver_conflict_waiver_suppresses():
sch = _sch(
[
_comp("U1", [_pin("1", etype=PinType.OUTPUT)]),
_comp("U2", [_pin("1", etype=PinType.OUTPUT)]),
],
[_net("LED1_GPIO_RD", [("U1", "1"), ("U2", "1")])],
)
cfg = _cfg(waivers=[{"net": "LED1_GPIO_RD", "rule": "driver_conflict",
"reason": "shared open-drain STAT by design"}])
assert _by_code(erc.run(sch, cfg), erc.ERC_DRIVER_CONFLICT) == []
# without the waiver it fires
assert len(_by_code(erc.run(sch, _cfg()), erc.ERC_DRIVER_CONFLICT)) == 1
# ---------------------------------------------------------------------------
# floating input (TYPE-gated by confidence)
# ---------------------------------------------------------------------------
def test_floating_input_high_confidence_is_warning():
# INPUT + PASSIVE, no driver -> floating. confidence 0.5 -> WARNING.
sch = _sch(
[_comp("U1", [_pin("1", name="P0.25", etype=PinType.INPUT)]),
_comp("R1", [_pin("1")])],
[_net("SIG", [("U1", "1"), ("R1", "1")])],
)
fl = _by_code(erc.run(sch, _cfg()), erc.ERC_FLOATING_INPUT)
assert len(fl) == 1
assert fl[0].refs == ["U1.1"]
assert fl[0].severity is Severity.WARNING
def test_floating_input_low_confidence_is_downgraded_to_note():
extra = _comp("R2", [_pin(str(i)) for i in range(1, 9)]) # dilute confidence
sch = _sch(
[_comp("U1", [_pin("1", etype=PinType.INPUT)]),
_comp("R1", [_pin("1")]), extra],
[_net("SIG", [("U1", "1"), ("R1", "1")])],
)
fl = _by_code(erc.run(sch, _cfg()), erc.ERC_FLOATING_INPUT)
assert len(fl) == 1
assert fl[0].severity is Severity.NOTE
def test_input_with_driver_is_not_floating():
sch = _sch(
[_comp("U1", [_pin("1", etype=PinType.INPUT)]),
_comp("U2", [_pin("1", etype=PinType.OUTPUT)])],
[_net("SIG", [("U1", "1"), ("U2", "1")])],
)
assert _by_code(erc.run(sch, _cfg()), erc.ERC_FLOATING_INPUT) == []
def _pullup_sch(*, r_lib="Device:R", dnp=False, top="V3V3"):
"""U1.1 (INPUT) held by R1 to ``top`` — the canonical EN/reset pull-up."""
return _sch(
[_comp("U1", [_pin("1", name="EN", etype=PinType.INPUT)]),
_comp("R1", [_pin("1"), _pin("2")], lib=r_lib, value="10k", dnp=dnp),
_comp("C1", [_pin("1"), _pin("2")], lib="Device:C", value="100n")],
[_net("EN", [("U1", "1"), ("R1", "1"), ("C1", "1")]),
_net(top, [("R1", "2")], source_names=[top]),
_net("GND", [("C1", "2")], source_names=["GND"])],
)
def test_passive_pullup_to_a_rail_is_a_driver():
# 10k from EN to +3V3 is how EN is CORRECTLY driven; its pins are Passive,
# so a type-only rule called the input floating on every real board.
assert _by_code(erc.run(_pullup_sch(), _cfg()), erc.ERC_FLOATING_INPUT) == []
def test_dnp_pullup_leaves_the_input_floating():
# An unpopulated pull-up ties nothing on the shipped board.
fl = _by_code(erc.run(_pullup_sch(dnp=True), _cfg()), erc.ERC_FLOATING_INPUT)
assert [f.refs for f in fl] == [["U1.1"]]
def test_passive_to_a_non_rail_net_is_not_a_driver():
# R1's far side is an ordinary signal, not a rail: nothing holds EN.
fl = _by_code(erc.run(_pullup_sch(top="SPARE_LN"), _cfg()),
erc.ERC_FLOATING_INPUT)
assert [f.refs for f in fl] == [["U1.1"]]
def test_capacitor_to_ground_alone_is_not_a_driver():
# A cap to GND stores charge; it does not hold a level. (Same board as
# the pull-up case with R1 removed.)
sch = _sch(
[_comp("U1", [_pin("1", name="EN", etype=PinType.INPUT)]),
_comp("C1", [_pin("1"), _pin("2")], lib="Device:C", value="100n")],
[_net("EN", [("U1", "1"), ("C1", "1")]),
_net("GND", [("C1", "2")], source_names=["GND"])],
)
fl = _by_code(erc.run(sch, _cfg()), erc.ERC_FLOATING_INPUT)
assert [f.refs for f in fl] == [["U1.1"]]
def test_connector_on_the_net_is_a_driver():
# SWDCLK is driven by the debug probe, which is not on this schematic.
sch = _sch(
[_comp("U1", [_pin("1", name="SWDCLK", etype=PinType.INPUT)]),
_comp("J4", [_pin("1"), _pin("2")], lib="Connector_Generic:Conn_01x06")],
[_net("SWDCLK", [("U1", "1"), ("J4", "1")]),
_net("GND", [("J4", "2")], source_names=["GND"])],
)
assert _by_code(erc.run(sch, _cfg()), erc.ERC_FLOATING_INPUT) == []
def test_input_on_power_rail_is_not_floating():
# An INPUT tied to a detected rail is driven by the rail, not floating.
sch = _sch(
[_comp("U1", [_pin("1", etype=PinType.INPUT)]),
_comp("U2", [_pin("1")])],
[_net("GND", [("U1", "1"), ("U2", "1")], source_names=["GND"])],
)
assert _by_code(erc.run(sch, _cfg()), erc.ERC_FLOATING_INPUT) == []
# ---------------------------------------------------------------------------
# IC power / ground by NET IDENTITY (robust; not type-gated)
# ---------------------------------------------------------------------------
def _powered_board() -> Schematic:
# GND {U1.1, R1.1}; V3V3 {U1.2, U2.1}. U1 has both rails; U2 only power.
# ICs carry >= 3 pins: parts below that are treated as header/jumper stubs
# and skipped by the per-IC power/ground check.
return _sch(
[
_comp("U1", [_pin("1"), _pin("2"), _pin("3")]),
_comp("U2", [_pin("1"), _pin("2"), _pin("3")]),
_comp("R1", [_pin("1")]),
],
[
_net("GND", [("U1", "1"), ("R1", "1")], source_names=["GND"]),
_net("V3V3", [("U1", "2"), ("U2", "1")], source_names=["V3V3"]),
],
)
def test_ic_with_power_and_ground_is_clean():
out = erc.run(_powered_board(), _cfg())
assert not any(f.refs == ["U1"] for f in out
if f.code in (erc.ERC_NO_POWER, erc.ERC_NO_GROUND))
def test_ic_missing_ground_flagged_by_identity():
out = erc.run(_powered_board(), _cfg())
ng = _by_code(out, erc.ERC_NO_GROUND)
assert any(f.refs == ["U2"] for f in ng) # U2 touches no ground net
assert _by_code(out, erc.ERC_NO_POWER) == [] # U2 touches power -> ok
def test_two_pin_u_part_is_not_an_ic():
# A "U"-designated 2-pin part (e.g. a buzzer or header U9) must not be flagged
# for missing power/ground -- it is a header/jumper stub, not an IC.
sch = _sch(
[
_comp("U1", [_pin("1"), _pin("2"), _pin("3")]),
_comp("U9", [_pin("1"), _pin("2")]),
],
[
_net("GND", [("U1", "1")], source_names=["GND"]),
_net("V3V3", [("U1", "2")], source_names=["V3V3"]),
_net("OSC_OUT", [("U9", "1")]),
],
)
out = erc.run(sch, _cfg())
assert not any(
f.refs == ["U9"] for f in out
if f.code in (erc.ERC_NO_POWER, erc.ERC_NO_GROUND)
)
def test_tvs_array_is_not_asked_for_a_power_connection():
# A TPD4E05U06-class ESD array is a "U" part with no supply pin at all:
# every channel is a clamp to GND. Demanding power from it was a
# guaranteed false positive on any board carrying one.
sch = _sch(
[
_comp("U1", [_pin("1"), _pin("2"), _pin("3")]),
_comp("U6", [_pin("1"), _pin("2"), _pin("3")],
lib="corpus_jlc:TPD4E05U06DQAR", value="TPD4E05U06"),
],
[
_net("GND", [("U1", "1"), ("U6", "3")], source_names=["GND"]),
_net("V3V3", [("U1", "2")], source_names=["V3V3"]),
_net("USB_DP", [("U1", "3"), ("U6", "1")]),
],
)
out = erc.run(sch, _cfg())
assert _by_code(out, erc.ERC_NO_POWER) == []
# the GROUND half still applies to a clamp — here it IS grounded
assert _by_code(out, erc.ERC_NO_GROUND) == []
def test_tvs_array_without_ground_is_still_flagged():
sch = _sch(
[
_comp("U1", [_pin("1"), _pin("2"), _pin("3")]),
_comp("U6", [_pin("1"), _pin("2"), _pin("3")],
lib="Power_Protection:USBLC6-2SC6", value="USBLC6"),
],
[
_net("GND", [("U1", "1")], source_names=["GND"]),
_net("V3V3", [("U1", "2")], source_names=["V3V3"]),
_net("USB_DP", [("U1", "3"), ("U6", "1")]),
],
)
out = erc.run(sch, _cfg())
assert [f.refs for f in _by_code(out, erc.ERC_NO_GROUND)] == [["U6"]]
assert _by_code(out, erc.ERC_NO_POWER) == []
def test_no_power_or_ground_infra_skips_per_ic_checks():
# No GND/power net anywhere -> do not spam every IC (vacuous-board guard).
sch = _sch(
[_comp("U1", [_pin("1")]), _comp("U2", [_pin("1")])],
[_net("STAT", [("U1", "1"), ("U2", "1")])],
)
out = erc.run(sch, _cfg())
assert _by_code(out, erc.ERC_NO_POWER) == []
assert _by_code(out, erc.ERC_NO_GROUND) == []
def test_derived_rail_name_counts_as_power():
# The same copper is VBUS_CONN at the receptacle and VBUS_FUSED after the
# PTC; exact-name matching saw neither as power and reported every IC on
# them as unpowered.
# U4 (ground only) proves the rule is live on this board.
sch = _sch(
[_comp("U3", [_pin("1"), _pin("2"), _pin("3")]),
_comp("U4", [_pin("1"), _pin("2"), _pin("3")])],
[_net("GND", [("U3", "3"), ("U4", "3")], source_names=["GND"]),
_net("VBUS_FUSED", [("U3", "1")], source_names=["VBUS_FUSED"]),
_net("SPARE", [("U4", "1")])],
)
out = erc.run(sch, _cfg())
assert [f.refs for f in _by_code(out, erc.ERC_NO_POWER)] == [["U4"]]
def test_rail_named_signal_does_not_count_as_power():
# ...but VBUS_DET is a detect LINE, not the rail: an IC whose only
# "VBUS-ish" net is the detect signal is still unpowered.
sch = _sch(
[_comp("U1", [_pin("1"), _pin("2"), _pin("3")]),
_comp("U3", [_pin("1"), _pin("2"), _pin("3")])],
[_net("GND", [("U1", "3"), ("U3", "3")], source_names=["GND"]),
_net("V3V3", [("U1", "1")], source_names=["V3V3"]),
_net("VBUS_DET", [("U3", "2"), ("U1", "2")],
source_names=["VBUS_DET"])],
)
out = erc.run(sch, _cfg())
assert [f.refs for f in _by_code(out, erc.ERC_NO_POWER)] == [["U3"]]
def test_config_rail_name_detected_as_power():
# "MIDRAIL" matches no built-in pattern; config [[rail]] makes it power.
sch = _sch(
[_comp("U1", [_pin("1"), _pin("2")]), _comp("R1", [_pin("1")])],
[
_net("GND", [("U1", "1"), ("R1", "1")], source_names=["GND"]),
_net("MIDRAIL", [("U1", "2"), ("R1", "1")], source_names=["MIDRAIL"]),
],
)
cfg = _cfg(rails=[{"name": "MIDRAIL", "voltage": 1.65}])
out = erc.run(sch, cfg)
# U1 now touches both a (config) power net and ground -> not flagged.
assert not any(f.refs == ["U1"] for f in out
if f.code in (erc.ERC_NO_POWER, erc.ERC_NO_GROUND))
def test_no_power_waiver_suppresses():
# U2 lacks ground; a no_ground waiver keyed by its designator silences it.
cfg = _cfg(waivers=[{"net": "U2", "rule": "no_ground",
"reason": "analog-only part, no ground pin"}])
assert _by_code(erc.run(_powered_board(), cfg), erc.ERC_NO_GROUND) == []
# ---------------------------------------------------------------------------
# net-alias conflict -> NOTE (never an error)
# ---------------------------------------------------------------------------
def test_net_alias_is_a_note():
net = _net(
"STAT",
[("U1", "1"), ("U2", "1")],
aliases=["LED1_GPIO_RD"],
source_names=["STAT", "LED1_GPIO_RD"],
)
sch = _sch(
[_comp("U1", [_pin("1")]), _comp("U2", [_pin("1")])],
[net],
)
alias = _by_code(erc.run(sch, _cfg()), erc.ERC_NET_ALIAS)
assert len(alias) == 1
assert alias[0].severity is Severity.NOTE
assert "STAT" in alias[0].message and "LED1_GPIO_RD" in alias[0].message
def test_single_name_net_has_no_alias_note():
sch = _sch(
[_comp("U1", [_pin("1")]), _comp("U2", [_pin("1")])],
[_net("STAT", [("U1", "1"), ("U2", "1")])],
)
assert _by_code(erc.run(sch, _cfg()), erc.ERC_NET_ALIAS) == []
def test_net_alias_waiver_suppresses():
net = _net("STAT", [("U1", "1"), ("U2", "1")],
aliases=["LED1_GPIO_RD"], source_names=["STAT", "LED1_GPIO_RD"])
sch = _sch([_comp("U1", [_pin("1")]), _comp("U2", [_pin("1")])], [net])
cfg = _cfg(waivers=[{"net": "STAT", "rule": "net_alias", "reason": "ok"}])
assert _by_code(erc.run(sch, cfg), erc.ERC_NET_ALIAS) == []
# ---------------------------------------------------------------------------
# unplaced units of multi-unit parts (KiCad-sourced only)
# ---------------------------------------------------------------------------
def _multi_unit_comp(
designator: str,
part_count: int,
placed_units: list[int],
*,
common_pins: int = 0,
undesignated: bool = False,
) -> Component:
# Mirror the KiCad reader's merge: only placed units contribute pins,
# each pin tagged with its owning unit (0 = common to all units).
pins = [
Pin(number=f"{u}{i}", name=None, x_mil=0.0, y_mil=0.0, owner_part_id=u)
for u in placed_units
for i in (1, 2)
]
pins += [
Pin(number=f"c{i}", name=None, x_mil=0.0, y_mil=0.0, owner_part_id=0)
for i in range(common_pins)
]
return Component(
designator=designator,
library_ref="Amplifier:LM324",
x_mil=0.0,
y_mil=0.0,
part_count=part_count,
pins=pins,
undesignated=undesignated,
)
def test_unplaced_unit_flagged_with_macro_hint():
sch = _sch([_multi_unit_comp("U1", 4, [1, 3])], [], source_format="kicad")
out = _by_code(erc.run(sch, _cfg()), erc.ERC_UNPLACED_UNIT)
assert len(out) == 1
assert out[0].severity is Severity.WARNING
assert out[0].refs == ["U1"]
# names the missing units by KiCad letter and points at the macro
assert "B, D" in out[0].message
assert "terminate_unused_unit" in out[0].message
def test_all_units_placed_is_clean():
sch = _sch([_multi_unit_comp("U1", 2, [1, 2])], [], source_format="kicad")
assert _by_code(erc.run(sch, _cfg()), erc.ERC_UNPLACED_UNIT) == []
def test_common_unit0_pins_do_not_count_as_placed():
# A part whose only extra pins are _0_* common ones still misses unit B.
sch = _sch(
[_multi_unit_comp("U1", 2, [1], common_pins=2)], [], source_format="kicad"
)
out = _by_code(erc.run(sch, _cfg()), erc.ERC_UNPLACED_UNIT)
assert len(out) == 1 and "B" in out[0].message
def test_single_unit_part_never_flagged():
sch = _sch([_multi_unit_comp("U1", 1, [1])], [], source_format="kicad")
assert _by_code(erc.run(sch, _cfg()), erc.ERC_UNPLACED_UNIT) == []
def test_unplaced_unit_silent_for_altium_sources():
# Altium attaches every unit's pins regardless of placement and PARTCOUNT
# is unreliable -> the rule must stay silent for altium-format schematics.
sch = _sch([_multi_unit_comp("U1", 4, [1])], [], source_format="altium")
assert _by_code(erc.run(sch, _cfg()), erc.ERC_UNPLACED_UNIT) == []
def test_unplaced_unit_skips_undesignated():
sch = _sch(
[_multi_unit_comp("$U0", 2, [1], undesignated=True)], [],
source_format="kicad",
)
assert _by_code(erc.run(sch, _cfg()), erc.ERC_UNPLACED_UNIT) == []
def test_unplaced_unit_waiver_suppresses():
sch = _sch([_multi_unit_comp("U1", 2, [1])], [], source_format="kicad")
cfg = _cfg(waivers=[{"net": "U1", "rule": "unplaced_unit",
"reason": "spare gate intentionally unplaced"}])
assert _by_code(erc.run(sch, cfg), erc.ERC_UNPLACED_UNIT) == []
assert len(_by_code(erc.run(sch, _cfg()), erc.ERC_UNPLACED_UNIT)) == 1
# ---------------------------------------------------------------------------
# real-fixture smoke tests (parsed via the frozen Altium reader)
# ---------------------------------------------------------------------------
def test_real_fixture_runs():
sch = altium_sch.read(os.path.join(FIXTURES, "shared_name_label.SchDoc"))
out = erc.run(sch, _cfg())
assert all(isinstance(f, Finding) for f in out)
# All four pins are Passive -> the type-based rules must stay silent.
assert _by_code(out, erc.ERC_DRIVER_CONFLICT) == []
assert _by_code(out, erc.ERC_FLOATING_INPUT) == []
def test_real_fixture_no_erc_suppresses_dangling():
# no_erc.SchDoc: U1.2 is an open pin carrying a RECORD-22 No-ERC marker.
sch = altium_sch.read(os.path.join(FIXTURES, "no_erc.SchDoc"))
assert sch.no_erc_points # reader surfaced the marker
out = erc.run(sch, _cfg())
# the lone single-pin net (U1.2) is suppressed by the No-ERC marker
assert _by_code(out, erc.ERC_DANGLING_NET) == []
# ...and a marker on a genuinely-open pin never reads as a contradiction
assert _by_code(out, erc.NC_PIN_CONNECTED) == []
@pytest.mark.parametrize(
"fixture",
[
"shared_name_label.SchDoc",
"junction_cross.SchDoc",
"no_erc.SchDoc",
"t_junction.SchDoc",
"two_gnd_ports.SchDoc",
],
)
def test_all_fixtures_run_without_error(fixture):
sch = altium_sch.read(os.path.join(FIXTURES, fixture))
out = erc.run(sch, _cfg())
assert isinstance(out, list)
assert all(isinstance(f, Finding) for f in out)
# ---------------------------------------------------------------------------
# pin-type conflict matrix (ERC_PIN_CONFLICT)
# ---------------------------------------------------------------------------
def _two_pin_net(etype_a: PinType, etype_b: PinType):
u1 = _comp("U1", [_pin("1", etype=etype_a)])
u2 = _comp("U2", [_pin("1", etype=etype_b)])
net = _net("SIG", [("U1", "1"), ("U2", "1")])
return _sch([u1, u2], [net])
@pytest.mark.parametrize(
"a,b,sev",
[
(PinType.OPEN_COLLECTOR, PinType.OUTPUT, Severity.WARNING),
(PinType.OPEN_COLLECTOR, PinType.POWER_OUT, Severity.WARNING),
(PinType.OPEN_EMITTER, PinType.OUTPUT, Severity.WARNING),
(PinType.OPEN_EMITTER, PinType.POWER_OUT, Severity.WARNING),
(PinType.TRI_STATE, PinType.POWER_OUT, Severity.WARNING),
(PinType.TRI_STATE, PinType.OUTPUT, Severity.NOTE),
(PinType.OPEN_COLLECTOR, PinType.OPEN_EMITTER, Severity.NOTE),
],
)
def test_pin_conflict_matrix_cells(a, b, sev):
findings = erc.run(_two_pin_net(a, b))
hits = _by_code(findings, "ERC_PIN_CONFLICT")
assert len(hits) == 1
assert hits[0].severity is sev
assert "U1.1" in hits[0].refs and "U2.1" in hits[0].refs
def test_pin_conflict_safe_pairs_are_silent():
for a, b in [
(PinType.OPEN_COLLECTOR, PinType.OPEN_COLLECTOR), # wired-OR is fine
(PinType.INPUT, PinType.OUTPUT),
(PinType.PASSIVE, PinType.OUTPUT),
(PinType.TRI_STATE, PinType.TRI_STATE),
]:
findings = erc.run(_two_pin_net(a, b))
assert _by_code(findings, "ERC_PIN_CONFLICT") == [], (a, b)
def test_pin_conflict_demoted_at_low_type_confidence():
# 2 typed pins out of 12 -> confidence < 0.2, and the net carries an
# untyped member (R1.1) -> NOTE with the caveat text
passives = _comp("R1", [_pin(str(i), etype=PinType.PASSIVE, x=float(i))
for i in range(1, 11)])
u1 = _comp("U1", [_pin("1", etype=PinType.OPEN_COLLECTOR)])
u2 = _comp("U2", [_pin("1", etype=PinType.OUTPUT)])
net = _net("SIG", [("U1", "1"), ("U2", "1"), ("R1", "1")])
findings = erc.run(_sch([passives, u1, u2], [net]))
hits = _by_code(findings, "ERC_PIN_CONFLICT")
assert len(hits) == 1 and hits[0].severity is Severity.NOTE
assert "type-confidence low" in hits[0].message
def test_pin_conflict_fully_typed_net_keeps_severity():
# Same board confidence, fully-typed net -> full severity (per-NET rule).
passives = _comp("R1", [_pin(str(i), etype=PinType.PASSIVE, x=float(i))
for i in range(1, 11)])
u1 = _comp("U1", [_pin("1", etype=PinType.OPEN_COLLECTOR)])
u2 = _comp("U2", [_pin("1", etype=PinType.OUTPUT)])
net = _net("SIG", [("U1", "1"), ("U2", "1")])
hits = _by_code(erc.run(_sch([passives, u1, u2], [net])), "ERC_PIN_CONFLICT")
assert len(hits) == 1 and hits[0].severity is Severity.WARNING
assert "type-confidence low" not in hits[0].message
def test_floating_input_fully_typed_net_keeps_severity():
# INPUT + INPUT, both typed, on an otherwise-untyped board: no driver and
# no untyped pin to hide one -> WARNING, not NOTE.
passives = _comp("R1", [_pin(str(i), etype=PinType.PASSIVE, x=float(i))
for i in range(1, 11)])
u1 = _comp("U1", [_pin("1", name="RXD", etype=PinType.INPUT)])
u2 = _comp("U2", [_pin("1", name="CTS", etype=PinType.INPUT)])
net = _net("SIG", [("U1", "1"), ("U2", "1")])
hits = _by_code(erc.run(_sch([passives, u1, u2], [net])),
erc.ERC_FLOATING_INPUT)
assert len(hits) == 2
assert all(h.severity is Severity.WARNING for h in hits)
def test_power_in_undriven_fully_typed_net_keeps_severity():
passives = _comp("R1", [_pin(str(i), etype=PinType.PASSIVE, x=float(i))
for i in range(1, 11)])
u1 = _comp("U1", [_pin("1", name="VDD", etype=PinType.POWER_IN)])
u2 = _comp("U2", [_pin("1", etype=PinType.OUTPUT)])
net = _net("SIG", [("U1", "1"), ("U2", "1")])
hits = _by_code(erc.run(_sch([passives, u1, u2], [net])),
erc.ERC_POWER_IN_UNDRIVEN)
assert len(hits) == 1 and hits[0].severity is Severity.WARNING
def test_pin_conflict_waiver():
findings = erc.run(
_two_pin_net(PinType.OPEN_COLLECTOR, PinType.OUTPUT),
_cfg(waivers=[{"rule": "pin_conflict", "net": "SIG"}]),
)
assert _by_code(findings, "ERC_PIN_CONFLICT") == []
def test_pin_conflict_no_erc_suppression():
u1 = _comp("U1", [_pin("1", etype=PinType.OPEN_COLLECTOR, x=100, y=100)])
u2 = _comp("U2", [_pin("1", etype=PinType.OUTPUT, x=200, y=100)])
net = _net("SIG", [("U1", "1"), ("U2", "1")])
findings = erc.run(_sch([u1, u2], [net], no_erc_points=[(100.0, 100.0)]))
assert _by_code(findings, "ERC_PIN_CONFLICT") == []
# ---------------------------------------------------------------------------
# undriven POWER_IN (ERC_POWER_IN_UNDRIVEN)
# ---------------------------------------------------------------------------
def test_power_in_undriven_flagged():
u1 = _comp("U1", [_pin("1", name="VDD", etype=PinType.POWER_IN)])
u2 = _comp("U2", [_pin("1", etype=PinType.OUTPUT)])
net = _net("SIG", [("U1", "1"), ("U2", "1")])
findings = erc.run(_sch([u1, u2], [net]))
hits = _by_code(findings, "ERC_POWER_IN_UNDRIVEN")
assert len(hits) == 1 and hits[0].severity is Severity.WARNING
assert "U1.1" in hits[0].refs
def test_power_in_on_named_rail_is_trusted():
u1 = _comp("U1", [_pin("1", name="VDD", etype=PinType.POWER_IN)])
u2 = _comp("U2", [_pin("1", etype=PinType.PASSIVE)])
net = _net("3V3", [("U1", "1"), ("U2", "1")])
findings = erc.run(_sch([u1, u2], [net]))
assert _by_code(findings, "ERC_POWER_IN_UNDRIVEN") == []
def test_power_in_driven_by_power_out_is_silent():
u1 = _comp("U1", [_pin("1", name="VDD", etype=PinType.POWER_IN)])
u2 = _comp("U2", [_pin("1", etype=PinType.POWER_OUT)])
net = _net("SIG", [("U1", "1"), ("U2", "1")])
findings = erc.run(_sch([u1, u2], [net]))
assert _by_code(findings, "ERC_POWER_IN_UNDRIVEN") == []
def test_power_in_undriven_waiver():
u1 = _comp("U1", [_pin("1", etype=PinType.POWER_IN)])
u2 = _comp("U2", [_pin("1", etype=PinType.OUTPUT)])
net = _net("SIG", [("U1", "1"), ("U2", "1")])
findings = erc.run(
_sch([u1, u2], [net]),
_cfg(waivers=[{"rule": "power_in_undriven", "net": "SIG"}]),
)
assert _by_code(findings, "ERC_POWER_IN_UNDRIVEN") == []