Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.
"""BOM hygiene checks (SPEC §3.6).
``run(sch)`` inspects only the *component* layer of a :class:`model.Schematic`
(no net inference needed) and reports five classes of issue:
* **duplicate designator** -- two or more genuinely distinct components share a
refdes. Multi-unit parts (several placements of one physical component sharing
a ``UniqueId``) are *deduped*, not flagged.
* **refdes gap** -- a hole in a numeric-suffixed refdes sequence (e.g. ``R7`` and
``R12`` present but ``R8..R11`` missing). Gap detection runs **only within a
numeric-suffixed prefix**: compound / manually-named refs such as ``J_USB_C``
do not parse to ``(prefix, int)`` and are skipped, and a lone member (``X3``
with no ``X1``/``X2``) has an empty min..max range so it never reports a gap.
* **corrupt text** -- a component whose value or parameters contain the U+FFFD
replacement character. The corruption is baked into the ``.SchDoc`` at export
time (typically a GBK/CP125x-locale value pushed through a lossy UTF-8 decode
by the authoring/import tool -- both the ANSI field and its ``%UTF8%`` twin
carry the damage), so **no decoder can recover it**; the fix is to re-export
from a tool that preserves the text. One aggregated NOTE per schematic.
* **missing value** / **missing footprint** -- a component with no value or no
footprint string. A component whose ``value`` is empty but that carries a
concrete *part identity* -- a part-number parameter (``Manufacturer Part``,
``LCSC Part Name``, ...) or a digit-bearing ``library_ref`` (``AO2301``,
``MCP73831T-2ACI/OT``) -- is **not** flagged: Altium designs sourced from
vendor libraries routinely leave Comment/Value blank and identify the part
by its library reference, and flagging those drowned real findings in noise.
Synthesized (undesignated, ``$U<idx>``) components carry no real refdes and are
excluded from every BOM check; their count is already surfaced in the schematic
metadata header so the report is never vacuously clean.
"""
from __future__ import annotations
from .. import bom_policy
from .. import classify
from ..model import Component, Schematic
from ..report import Finding, Severity, anchor
__all__ = ["run"]
# A "simple" / numeric-suffixed refdes: one or more letters then digits, nothing
# else (``R12`` -> ("R", 12), ``U3`` -> ("U", 3)). Compound refs like ``J_USB_C``
# (an underscore-bearing manual name) do NOT match and are skipped from gaps.
# The sortable-sequence split lives in classify.split_refdes (the second
# refdes question, beside refdes_prefix); local name kept for the check body.
_parse_refdes = classify.split_refdes
def _clean(s: str | None) -> str | None:
"""Normalize a value/footprint string: blank/whitespace -> ``None``."""
if s is None:
return None
s = s.strip()
return s or None
# Parameter names that carry a concrete part number (vendor-library exports
# plus common distributor field conventions).
_PART_ID_PARAMS: tuple[str, ...] = (
"Manufacturer Part",
"MPN",
"Mpn",
"mpn",
"LCSC Part Name",
"LCSC Part",
"LCSC",
"JLCPCB Part",
"JLCPCB#",
"Supplier Part",
"Part Number",
"PartNumber",
"Manufacturer_Part_Number",
"DigiKey_PN",
"Digi-Key_PN",
"DK_PN",
"Mouser_PN",
"MouserPN",
"Farnell",
"element14",
)
# Below this MPN coverage a board is not sourceable as-is. Small sheets are
# exempt — a 3-part test jig is not a BOM. Both knobs are project-tunable
# via akcli.toml [bom] (coverage_floor / coverage_min_parts).
_MPN_COVERAGE_FLOOR = 0.5
_MPN_COVERAGE_MIN_PARTS = 10
def _bom_prefixes(cfg: object) -> tuple[str, ...]:
tbl = getattr(cfg, "bom", None) or {}
classes = tbl.get("classes") or {}
np = classes.get("no_part")
if isinstance(np, list) and np:
return tuple(str(x) for x in np)
return bom_policy.DEFAULT_NO_PART_PREFIXES
def _part_identity(comp: Component) -> str | None:
"""A concrete part identity that substitutes for a blank value, or ``None``.
A part-number parameter wins; else a ``library_ref`` that contains a digit
(real part numbers do -- ``AO2301``; generic symbols -- ``R``, ``LED`` --
do not, so those still report a missing value).
"""
params = comp.parameters or {}
for key in _PART_ID_PARAMS:
v = _clean(params.get(key))
if v:
return v
lib = _clean(comp.library_ref)
if lib and any(ch.isdigit() for ch in lib):
return lib
return None
def _identity(comp: Component) -> object:
"""A dedup key: ``(UniqueId, sheet instance)``, else per-object distinct.
Components sharing a non-empty ``UniqueId`` on the SAME sheet instance are
units of one physical part (deduped). The sheet qualifier matters: a sheet
file instantiated twice re-uses the file's symbol uuid for BOTH physical
parts, so a bare ``UniqueId`` key silently merged two instances carrying
the same (duplicate!) refdes into one — hiding exactly the
``BOM_DUPLICATE_DESIGNATOR`` case it must catch. A missing id makes each
placement its own identity.
"""
uid = _clean(comp.unique_id)
if uid is None:
return ("__no_uid__", id(comp))
return (uid, getattr(comp, "sheet", "") or "")
def _pos(comp: Component) -> tuple[float, float]:
return (comp.x_mil, comp.y_mil)
def _real_components(sch: Schematic) -> list[Component]:
"""Components eligible for BOM checks.
Drops synthesized/undesignated placements and ``#``-prefixed virtual
parts (power ports, PWR_FLAG): those never appear on a BOM, so flagging
their missing value/footprint — or "gaps" in #PWR numbering — is noise.
"""
return [c for c in sch.components
if not c.undesignated
and _clean(c.designator)
and not c.designator.lstrip().startswith("#")]
def run(sch: Schematic, cfg: object = None) -> list[Finding]:
"""Run BOM hygiene checks on ``sch`` and return findings (possibly empty).
Components are first classified into the four BOM populations
(:func:`bom_policy.classify`): ``no-part`` (structural — ``in_bom=no``,
a TP/FID/MH-class refdes, or an explicit no-part sourcing) is excluded
from every check but counted; ``dnp`` keeps the structural checks and
skips the sourcing ones; ``external`` leaves the coverage denominator.
"""
findings: list[Finding] = []
prefixes = _bom_prefixes(cfg)
classes: dict[str, tuple[str, str]] = {}
excluded: dict[str, int] = {"no-part": 0, "dnp": 0, "external": 0}
comps = []
for c in _real_components(sch):
klass, note = bom_policy.classify(c, prefixes)
prior = classes.get(c.designator)
if prior is None:
classes[c.designator] = (klass, note)
if klass != "fitted":
excluded[klass] += 1
if klass == "no-part":
continue
comps.append(c)
def _class_of(desig: str) -> str:
return classes.get(desig, ("fitted", ""))[0]
if any(excluded.values()):
# never vacuously clean: exclusions are visible, not silent
findings.append(Finding(
code="BOM_CLASS_SUMMARY",
severity=Severity.INFO,
message=(
f"bom eligibility: {sum(1 for v in classes.values() if v[0] == 'fitted')} "
f"fitted, {excluded['dnp']} dnp, {excluded['external']} external, "
f"{excluded['no-part']} no-part (no-part excluded from checks)"
),
refs=[],
))
# Group by designator, preserving first-seen order of the designators.
groups: dict[str, list[Component]] = {}
for c in comps:
groups.setdefault(c.designator, []).append(c)
# --- duplicate designators (deduped by UniqueId / multi-unit part) ----------
for desig, members in groups.items():
if len(members) < 2:
continue
distinct = {_identity(c) for c in members}
if len(distinct) > 1:
# One anchor per distinct placement sharing the offending desig —
# the ambiguity IS "which of these is really `desig`".
seen_ids: set = set()
dup_anchors = []
for c in members:
ident = _identity(c)
if ident in seen_ids:
continue
seen_ids.add(ident)
dup_anchors.append(anchor("component", desig, _pos(c)))
findings.append(
Finding(
code="BOM_DUPLICATE_DESIGNATOR",
severity=Severity.ERROR,
message=(
f"designator {desig!r} is used by {len(distinct)} distinct "
f"components ({len(members)} placements)"
),
refs=[desig],
pos=_pos(members[0]),
anchors=dup_anchors,
)
)
# --- refdes gaps (only within a numeric-suffixed prefix) --------------------
by_prefix: dict[str, set[int]] = {}
for desig in groups: # one logical entry per designator
parsed = _parse_refdes(desig)
if parsed is None: # compound / manually-named ref (e.g. J_USB_C) -> skip
continue
prefix, num = parsed
by_prefix.setdefault(prefix, set()).add(num)
for prefix in sorted(by_prefix):
nums = by_prefix[prefix]
lo, hi = min(nums), max(nums)
missing = [n for n in range(lo, hi + 1) if n not in nums]
if missing:
refs = [f"{prefix}{n}" for n in missing]
findings.append(
Finding(
code="BOM_REFDES_GAP",
severity=Severity.NOTE,
message=(
f"refdes gap in {prefix!r} sequence ({prefix}{lo}..{prefix}{hi}): "
f"missing {', '.join(refs)}"
),
refs=refs,
)
)
# --- corrupt (U+FFFD-bearing) text, aggregated per schematic ----------------
corrupt: list[str] = []
corrupt_anchors = []
corrupt_pos: tuple[float, float] | None = None
for desig, members in groups.items():
for c in members:
texts = [c.value, *(c.parameters or {}).values()]
if any(t and "�" in t for t in texts):
corrupt.append(desig)
p = _pos(c)
corrupt_anchors.append(anchor("component", desig, p))
if corrupt_pos is None:
corrupt_pos = p
break
if corrupt:
findings.append(
Finding(
code="BOM_CORRUPT_TEXT",
severity=Severity.NOTE,
message=(
f"{len(corrupt)} component(s) carry U+FFFD-corrupted text in "
f"value/parameters ({', '.join(corrupt[:8])}"
f"{', ...' if len(corrupt) > 8 else ''}) -- the replacement "
"characters are baked into the file at export time and no "
"decoder can recover them; re-export from a tool that "
"preserves the original text"
),
refs=corrupt,
pos=corrupt_pos,
anchors=corrupt_anchors,
)
)
# --- missing value / footprint (per logical component) ----------------------
# dnp parts keep the structural checks above but skip the sourcing ones:
# nothing is bought for them, so a missing value/footprint/order id is
# not a sourcing hole.
for desig, members in groups.items():
if _class_of(desig) == "dnp":
continue
has_value = any(_clean(c.value) or _part_identity(c) for c in members)
has_footprint = any(_clean(c.footprint) for c in members)
pos = _pos(members[0])
if not has_value:
findings.append(
Finding(
code="BOM_MISSING_VALUE",
severity=Severity.WARNING,
message=f"component {desig} has no value",
refs=[desig],
pos=pos,
anchors=[anchor("component", desig, pos)],
)
)
if not has_footprint:
findings.append(
Finding(
code="BOM_MISSING_FOOTPRINT",
severity=Severity.WARNING,
message=f"component {desig} has no footprint",
refs=[desig],
pos=pos,
anchors=[anchor("component", desig, pos)],
)
)
# --- per-line missing order id (fitted only) ---------------------------------
# The aggregate coverage floor below hides individual holes (a mostly-
# covered board sails past the floor while individual parts stay
# unorderable) —
# each FITTED logical component without an explicit LCSC/MPN parameter
# gets its own NOTE.
for desig, members in groups.items():
if _class_of(desig) != "fitted":
continue
if any(bom_policy.order_id_of(c) for c in members):
continue
pos = _pos(members[0])
findings.append(
Finding(
code="BOM_MISSING_PART_ID",
severity=Severity.NOTE,
message=(
f"{desig} ({_clean(members[0].value) or 'no value'}) has no "
"LCSC/MPN parameter — not orderable as-is"
),
refs=[desig],
pos=pos,
anchors=[anchor("component", desig, pos)],
)
)
# --- assembly-attribute consistency ------------------------------------------
for desig, members in groups.items():
c0 = members[0]
if _class_of(desig) == "dnp" and any(
bom_policy.order_id_of(c) for c in members):
findings.append(
Finding(
code="BOM_DNP_HAS_ORDER_ID",
severity=Severity.NOTE,
message=(
f"{desig} is marked DNP but carries an order id "
f"({bom_policy.order_id_of(c0) or '?'}) — confirm the "
"intent (stale id, or should it be fitted?)"
),
refs=[desig],
pos=_pos(c0),
)
)
for c in sch.components:
if (not c.in_bom and c.on_board and not c.dnp
and not c.undesignated
and _clean(c.designator)
and not c.designator.lstrip().startswith("#")
and not bom_policy.is_no_part_ref(c.designator, prefixes)):
findings.append(
Finding(
code="BOM_CPL_INCONSISTENT",
severity=Severity.NOTE,
message=(
f"{c.designator} is excluded from the BOM (in_bom=no) "
"but placed on the board — assembly would need a part "
"the BOM doesn't list; mark it DNP or a structural "
"class if intended"
),
refs=[c.designator],
pos=_pos(c),
)
)
# --- MPN sourcing coverage ---------------------------------------------------
# Coverage over LOGICAL fitted components (multi-unit parts count once);
# a part counts as covered when any placement carries a part-number
# identity. dnp/external leave the denominator: nothing is sourced from
# the catalog for them.
bom_tbl = getattr(cfg, "bom", None) or {}
floor = float(bom_tbl.get("coverage_floor", _MPN_COVERAGE_FLOOR))
min_parts = int(bom_tbl.get("coverage_min_parts", _MPN_COVERAGE_MIN_PARTS))
fitted_groups = {d: m for d, m in groups.items() if _class_of(d) == "fitted"}
if len(fitted_groups) >= min_parts:
covered = sum(
1 for members in fitted_groups.values()
if any(_part_identity(c) for c in members)
)
cov = covered / len(fitted_groups)
if cov < floor:
findings.append(
Finding(
code="BOM_MPN_COVERAGE",
severity=Severity.WARNING,
message=(
f"only {covered}/{len(fitted_groups)} fitted components "
f"({cov:.0%}) carry a part-number identity "
f"(MPN/distributor field) -- below the "
f"{floor:.0%} sourcing floor, this BOM "
"cannot be ordered as-is; `akcli jlc bom --fix` can "
"fill LCSC parts"
),
refs=[],
detector="check.bom",
confidence="deterministic",
evidence={"source": "bom"},
)
)
return findings