Add lifecycle, errata, internal-features, and layout_rules checks.
Cache distributor EOL/NRND/RoHS without inventing replacements, flag catalogued errata pull-ups only, and store layout_rules with a closed kind enum so millimetres stay null unless the datasheet stated a number. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
"""Errata workarounds from a known-URL catalog. No HTML scrape."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from backend.pinscopex.models import ComponentConstraints, DesignGraph, Finding
|
||||||
|
from backend.pinscopex.passive_rail_check import (
|
||||||
|
_pin_name_tokens,
|
||||||
|
_resistor_to_power,
|
||||||
|
)
|
||||||
|
from backend.pinscopex.validate import _match_constraints
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Exact MPN → URL + structured workarounds. Empty by default so eval is quiet.
|
||||||
|
DEFAULT_ERRATA_CATALOG: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def check_errata(
|
||||||
|
graph: DesignGraph,
|
||||||
|
constraints_map: dict[str, ComponentConstraints] | None,
|
||||||
|
catalog: dict[str, dict] | None = None,
|
||||||
|
) -> list[Finding]:
|
||||||
|
cmap = constraints_map or {}
|
||||||
|
cat = DEFAULT_ERRATA_CATALOG if catalog is None else catalog
|
||||||
|
findings: list[Finding] = []
|
||||||
|
for ref, comp in sorted(graph.components.items()):
|
||||||
|
mpn = (comp.mpn or "").strip()
|
||||||
|
if not mpn:
|
||||||
|
continue
|
||||||
|
entry = cat.get(mpn)
|
||||||
|
if entry is None:
|
||||||
|
continue
|
||||||
|
url = (entry.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
log.info("errata skip %s: catalog row has no url", mpn)
|
||||||
|
continue
|
||||||
|
cons = _match_constraints(mpn, cmap)
|
||||||
|
for wa in entry.get("workarounds") or []:
|
||||||
|
kind = (wa.get("kind") or "").lower()
|
||||||
|
pin_name = (wa.get("pin_name") or "").strip()
|
||||||
|
if kind != "pullup" or not pin_name:
|
||||||
|
continue
|
||||||
|
net = _net_for_pin_name(graph, ref, cons, pin_name)
|
||||||
|
if not net:
|
||||||
|
continue
|
||||||
|
if _resistor_to_power(graph, net):
|
||||||
|
continue
|
||||||
|
findings.append(Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=mpn,
|
||||||
|
aspect="errata",
|
||||||
|
source="errata_check",
|
||||||
|
status="WARNING",
|
||||||
|
finding=(
|
||||||
|
f"{ref} {pin_name} is missing the errata pull-up on '{net}'."
|
||||||
|
),
|
||||||
|
why=wa.get("note") or "Vendor errata workaround is not on the schematic.",
|
||||||
|
recommendation="Add the pull-up described in the errata, or confirm the die revision.",
|
||||||
|
reference=url,
|
||||||
|
net=net,
|
||||||
|
pins=[f"{ref}.{pin_name}"],
|
||||||
|
rule_id="PS-ERRATA-001",
|
||||||
|
))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _net_for_pin_name(
|
||||||
|
graph: DesignGraph,
|
||||||
|
ref: str,
|
||||||
|
cons: ComponentConstraints | None,
|
||||||
|
pin_name: str,
|
||||||
|
) -> str | None:
|
||||||
|
comp = graph.components.get(ref)
|
||||||
|
if not comp:
|
||||||
|
return None
|
||||||
|
want = pin_name.upper()
|
||||||
|
for pin_num, net in comp.pins.items():
|
||||||
|
if (net or "").upper() == want:
|
||||||
|
return net
|
||||||
|
tokens = _pin_name_tokens(cons, pin_num)
|
||||||
|
if any(t.upper() == want or _token_match(t, pin_name) for t in tokens):
|
||||||
|
return net
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _token_match(token: str, pin_name: str) -> bool:
|
||||||
|
return bool(re.search(rf"(?:^|[_/]){re.escape(pin_name)}(?:$|[_/\d])", token, re.I))
|
||||||
@@ -27,6 +27,9 @@ from backend.pinscopex.thermal_check import check_thermal
|
|||||||
from backend.pinscopex.power_margin_check import check_power_margin
|
from backend.pinscopex.power_margin_check import check_power_margin
|
||||||
from backend.pinscopex.sequencing_check import check_power_sequencing
|
from backend.pinscopex.sequencing_check import check_power_sequencing
|
||||||
from backend.pinscopex.dnp_check import check_dnp_enables
|
from backend.pinscopex.dnp_check import check_dnp_enables
|
||||||
|
from backend.pinscopex.lifecycle import check_lifecycle
|
||||||
|
from backend.pinscopex.errata_check import check_errata
|
||||||
|
from backend.pinscopex.internal_features_check import check_internal_features
|
||||||
|
|
||||||
|
|
||||||
class EvalScores(BaseModel):
|
class EvalScores(BaseModel):
|
||||||
@@ -93,6 +96,9 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]:
|
|||||||
out.extend(check_power_margin(graph, cmap))
|
out.extend(check_power_margin(graph, cmap))
|
||||||
out.extend(check_power_sequencing(graph, cmap))
|
out.extend(check_power_sequencing(graph, cmap))
|
||||||
out.extend(check_dnp_enables(graph, cmap))
|
out.extend(check_dnp_enables(graph, cmap))
|
||||||
|
out.extend(check_lifecycle(graph, {}))
|
||||||
|
out.extend(check_errata(graph, cmap))
|
||||||
|
out.extend(check_internal_features(graph, cmap))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Open-drain / on-die pull-up pins from extracted internal_features."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.pinscopex.models import ComponentConstraints, DesignGraph, Finding
|
||||||
|
from backend.pinscopex.passive_rail_check import (
|
||||||
|
_pin_name_tokens,
|
||||||
|
_resistor_to_power,
|
||||||
|
)
|
||||||
|
from backend.pinscopex.validate import _match_constraints
|
||||||
|
|
||||||
|
|
||||||
|
def check_internal_features(
|
||||||
|
graph: DesignGraph,
|
||||||
|
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||||
|
) -> list[Finding]:
|
||||||
|
cmap = constraints_map or {}
|
||||||
|
findings: list[Finding] = []
|
||||||
|
for ref, comp in sorted(graph.components.items()):
|
||||||
|
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||||
|
feats = cons.internal_features if cons else None
|
||||||
|
if not feats or not feats.pullup_pins:
|
||||||
|
continue
|
||||||
|
for pin_name in feats.pullup_pins:
|
||||||
|
net = None
|
||||||
|
for pin_num, n in comp.pins.items():
|
||||||
|
tokens = _pin_name_tokens(cons, pin_num)
|
||||||
|
names = tokens or [n or "", str(pin_num)]
|
||||||
|
if any(
|
||||||
|
t.upper() == pin_name.upper() or (n or "").upper() == pin_name.upper()
|
||||||
|
for t in names
|
||||||
|
):
|
||||||
|
net = n
|
||||||
|
break
|
||||||
|
if not net:
|
||||||
|
continue
|
||||||
|
if _resistor_to_power(graph, net):
|
||||||
|
continue
|
||||||
|
findings.append(Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=comp.mpn or "",
|
||||||
|
aspect="internal_features",
|
||||||
|
source="internal_features_check",
|
||||||
|
status="WARNING",
|
||||||
|
finding=(
|
||||||
|
f"{ref} {pin_name} is listed as needing an external pull-up "
|
||||||
|
f"and net '{net}' has none."
|
||||||
|
),
|
||||||
|
why="internal_features.pullup_pins from the datasheet block diagram.",
|
||||||
|
recommendation="Add a pull-up to the I/O rail, or confirm an on-die pull is enabled.",
|
||||||
|
reference="internal_features",
|
||||||
|
net=net,
|
||||||
|
pins=[f"{ref}.{pin_name}"],
|
||||||
|
rule_id="PS-INT-001",
|
||||||
|
))
|
||||||
|
return findings
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Validate datasheet layout_rules. Distances stay null unless numeric."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout"})
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v: Any) -> float | None:
|
||||||
|
if v is None or v is False:
|
||||||
|
return None
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
try:
|
||||||
|
return float(str(v).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
|
||||||
|
"""Return (normalized rows, errors). Empty list is a valid skip."""
|
||||||
|
if not raw:
|
||||||
|
return [], []
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return [], ["layout_rules must be an array"]
|
||||||
|
ok: list[dict] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
for i, row in enumerate(raw):
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
errors.append(f"layout_rules[{i}] must be an object")
|
||||||
|
continue
|
||||||
|
kind = str(row.get("kind") or "").strip()
|
||||||
|
if kind not in KNOWN_KINDS:
|
||||||
|
errors.append(f"layout_rules[{i}] unknown kind {kind!r}")
|
||||||
|
continue
|
||||||
|
dist = _num(row.get("max_distance_mm"))
|
||||||
|
via = row.get("min_via_count")
|
||||||
|
via_i = None
|
||||||
|
if isinstance(via, int) and not isinstance(via, bool):
|
||||||
|
via_i = via
|
||||||
|
elif via is not None:
|
||||||
|
n = _num(via)
|
||||||
|
via_i = int(n) if n is not None else None
|
||||||
|
page = row.get("source_page")
|
||||||
|
page_i = int(page) if isinstance(page, int) else None
|
||||||
|
ok.append({
|
||||||
|
"kind": kind,
|
||||||
|
"pin": row.get("pin"),
|
||||||
|
"cap_value_hint": row.get("cap_value_hint"),
|
||||||
|
"max_distance_mm": dist,
|
||||||
|
"same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None,
|
||||||
|
"min_via_count": via_i,
|
||||||
|
"net_class": row.get("net_class"),
|
||||||
|
"note": row.get("note"),
|
||||||
|
"source_page": page_i,
|
||||||
|
})
|
||||||
|
return ok, errors
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Distributor lifecycle / RoHS — cached records only, never a guessed equivalent."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.pinscopex.models import ComponentType, DesignGraph, Finding
|
||||||
|
from backend.pinscopex.utils import safe_mpn
|
||||||
|
|
||||||
|
_EOL = re.compile(
|
||||||
|
r"\b(obsolete|eol|end\s*of\s*life|discontinued|last\s*time\s*buy|ltb)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_NRND = re.compile(
|
||||||
|
r"\b(nrnd|not\s+for\s+new\s+designs|not\s+recommended)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_ACTIVE = re.compile(r"\b(active|production|recommended)\b", re.I)
|
||||||
|
_ROHS_NO = re.compile(r"\b(non[-\s]?compliant|not\s+compliant|no)\b", re.I)
|
||||||
|
_ROHS_YES = re.compile(r"\b(rohs\s*\d*\s*compliant|compliant|yes|true)\b", re.I)
|
||||||
|
_ROHS_NA = re.compile(r"\b(not\s+applicable|n/?a|exempt)\b", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleRecord(BaseModel):
|
||||||
|
mpn: str
|
||||||
|
source: str = ""
|
||||||
|
lifecycle: str | None = None # active | nrnd | eol | unknown
|
||||||
|
rohs_compliant: bool | None = None
|
||||||
|
stock: int | None = None
|
||||||
|
lead_time: str | None = None
|
||||||
|
replacement: str | None = None
|
||||||
|
product_status_raw: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _status_lifecycle(raw: str) -> str | None:
|
||||||
|
s = (raw or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if _EOL.search(s):
|
||||||
|
return "eol"
|
||||||
|
if _NRND.search(s):
|
||||||
|
return "nrnd"
|
||||||
|
if _ACTIVE.search(s):
|
||||||
|
return "active"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _rohs(raw: str) -> bool | None:
|
||||||
|
s = (raw or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if _ROHS_NA.search(s):
|
||||||
|
return None
|
||||||
|
if _ROHS_NO.search(s):
|
||||||
|
return False
|
||||||
|
if _ROHS_YES.search(s):
|
||||||
|
return True
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _replacement(product: dict) -> str | None:
|
||||||
|
for key in ("ProductSubstitutions", "Substitutes", "replacement", "Replacement"):
|
||||||
|
val = product.get(key)
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
if isinstance(val, str) and val.strip():
|
||||||
|
return val.strip()
|
||||||
|
if isinstance(val, list) and val:
|
||||||
|
first = val[0]
|
||||||
|
if isinstance(first, str) and first.strip():
|
||||||
|
return first.strip()
|
||||||
|
if isinstance(first, dict):
|
||||||
|
for k in ("ManufacturerProductNumber", "ManufacturerPartNumber", "mpn"):
|
||||||
|
if first.get(k):
|
||||||
|
return str(first[k]).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_distributor_product(mpn: str, product: dict, *, source: str = "digikey") -> LifecycleRecord:
|
||||||
|
"""Map a DigiKey/Mouser/LCSC product dict. Unknown fields stay None."""
|
||||||
|
status = (
|
||||||
|
product.get("ProductStatus")
|
||||||
|
or product.get("productStatus")
|
||||||
|
or product.get("partLifeCycle")
|
||||||
|
or product.get("LifecycleStatus")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
rohs_raw = (
|
||||||
|
product.get("RoHSStatus")
|
||||||
|
or product.get("rohsStatus")
|
||||||
|
or product.get("rohs")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if isinstance(rohs_raw, bool):
|
||||||
|
rohs = rohs_raw
|
||||||
|
rohs_raw = "true" if rohs_raw else "false"
|
||||||
|
else:
|
||||||
|
rohs = _rohs(str(rohs_raw))
|
||||||
|
stock = product.get("QuantityAvailable")
|
||||||
|
if stock is None:
|
||||||
|
stock = product.get("stock")
|
||||||
|
try:
|
||||||
|
stock_i = int(stock) if stock is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
stock_i = None
|
||||||
|
lead = product.get("ManufacturerLeadWeeks") or product.get("lead_time") or product.get("LeadTime")
|
||||||
|
return LifecycleRecord(
|
||||||
|
mpn=mpn,
|
||||||
|
source=source,
|
||||||
|
lifecycle=_status_lifecycle(str(status)),
|
||||||
|
rohs_compliant=rohs,
|
||||||
|
stock=stock_i,
|
||||||
|
lead_time=str(lead) if lead not in (None, "") else None,
|
||||||
|
replacement=_replacement(product),
|
||||||
|
product_status_raw=str(status),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_lifecycle_dir(directory: str | Path) -> dict[str, LifecycleRecord]:
|
||||||
|
out: dict[str, LifecycleRecord] = {}
|
||||||
|
path = Path(directory)
|
||||||
|
if not path.is_dir():
|
||||||
|
return out
|
||||||
|
for f in path.glob("*.json"):
|
||||||
|
raw = json.loads(f.read_text())
|
||||||
|
rec = LifecycleRecord.model_validate(raw)
|
||||||
|
out[rec.mpn] = rec
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def write_lifecycle_record(directory: str | Path, rec: LifecycleRecord) -> Path:
|
||||||
|
path = Path(directory)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = path / f"{safe_mpn(rec.mpn)}.json"
|
||||||
|
dest.write_text(rec.model_dump_json(indent=2) + "\n")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _match_record(mpn: str | None, records: dict[str, LifecycleRecord]) -> LifecycleRecord | None:
|
||||||
|
if not mpn:
|
||||||
|
return None
|
||||||
|
if mpn in records:
|
||||||
|
return records[mpn]
|
||||||
|
norm = re.sub(r"[/_\-\s]", "", mpn).upper()
|
||||||
|
for key, rec in records.items():
|
||||||
|
if re.sub(r"[/_\-\s]", "", key).upper() == norm:
|
||||||
|
return rec
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_lifecycle(
|
||||||
|
graph: DesignGraph,
|
||||||
|
records: dict[str, LifecycleRecord] | None,
|
||||||
|
) -> list[Finding]:
|
||||||
|
recs = records or {}
|
||||||
|
findings: list[Finding] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for ref, comp in sorted(graph.components.items()):
|
||||||
|
if comp.component_type in (
|
||||||
|
ComponentType.MECHANICAL, ComponentType.FIDUCIAL, ComponentType.TEST_POINT,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
mpn = (comp.mpn or "").strip()
|
||||||
|
rec = _match_record(mpn, recs)
|
||||||
|
if rec is None:
|
||||||
|
continue
|
||||||
|
if mpn in seen:
|
||||||
|
continue
|
||||||
|
seen.add(mpn)
|
||||||
|
if rec.lifecycle == "eol":
|
||||||
|
rec_txt = (
|
||||||
|
f"Distributor replacement: {rec.replacement}."
|
||||||
|
if rec.replacement else
|
||||||
|
"No distributor replacement was listed."
|
||||||
|
)
|
||||||
|
findings.append(Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=mpn,
|
||||||
|
aspect="lifecycle",
|
||||||
|
source="lifecycle_check",
|
||||||
|
status="WARNING",
|
||||||
|
finding=f"{mpn} is EOL/obsolete ({rec.product_status_raw or 'eol'}).",
|
||||||
|
why="Distributor ProductStatus, not an LLM equivalent search.",
|
||||||
|
recommendation=rec_txt,
|
||||||
|
reference=rec.source or "distributor",
|
||||||
|
pins=[ref],
|
||||||
|
rule_id="PS-LF-001",
|
||||||
|
))
|
||||||
|
elif rec.lifecycle == "nrnd":
|
||||||
|
findings.append(Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=mpn,
|
||||||
|
aspect="lifecycle",
|
||||||
|
source="lifecycle_check",
|
||||||
|
status="INFO",
|
||||||
|
finding=f"{mpn} is NRND ({rec.product_status_raw or 'nrnd'}).",
|
||||||
|
why="Distributor ProductStatus.",
|
||||||
|
recommendation="Prefer an Active orderable if the design is new.",
|
||||||
|
reference=rec.source or "distributor",
|
||||||
|
pins=[ref],
|
||||||
|
rule_id="PS-LF-002",
|
||||||
|
))
|
||||||
|
if rec.rohs_compliant is False:
|
||||||
|
findings.append(Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=mpn,
|
||||||
|
aspect="lifecycle",
|
||||||
|
source="lifecycle_check",
|
||||||
|
status="WARNING",
|
||||||
|
finding=f"{mpn} is marked RoHS non-compliant.",
|
||||||
|
why="RoHS fail only when the distributor flag is explicit.",
|
||||||
|
recommendation="Choose a RoHS-compliant orderable of the same MPN family.",
|
||||||
|
reference=rec.source or "distributor",
|
||||||
|
pins=[ref],
|
||||||
|
rule_id="PS-LF-003",
|
||||||
|
))
|
||||||
|
return findings
|
||||||
@@ -44,6 +44,13 @@ def _check_subtype(v: object) -> str | None:
|
|||||||
return validate_subtype(str(v))
|
return validate_subtype(str(v))
|
||||||
|
|
||||||
|
|
||||||
|
class InternalFeatures(BaseModel):
|
||||||
|
"""Block-diagram extras: ESD clamps, on-die pull-ups, analog switches."""
|
||||||
|
esd_clamp_pins: list[str] = []
|
||||||
|
pullup_pins: list[str] = []
|
||||||
|
analog_switch: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class ComponentConstraints(BaseModel):
|
class ComponentConstraints(BaseModel):
|
||||||
mpn: str
|
mpn: str
|
||||||
model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor)
|
model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor)
|
||||||
@@ -52,6 +59,8 @@ class ComponentConstraints(BaseModel):
|
|||||||
pintable: list[Pin]
|
pintable: list[Pin]
|
||||||
absolute_maximum_ratings: list[AbsMaxRating]
|
absolute_maximum_ratings: list[AbsMaxRating]
|
||||||
rules: list[Rule]
|
rules: list[Rule]
|
||||||
|
internal_features: InternalFeatures | None = None
|
||||||
|
layout_rules: list[dict] = []
|
||||||
|
|
||||||
_validate_subtype = field_validator("component_subtype", mode="before")(
|
_validate_subtype = field_validator("component_subtype", mode="before")(
|
||||||
staticmethod(_check_subtype)
|
staticmethod(_check_subtype)
|
||||||
|
|||||||
@@ -125,6 +125,20 @@ PINTABLE_TOOL = {
|
|||||||
"required": ["parameter", "unit", "source_page"],
|
"required": ["parameter", "unit", "source_page"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"internal_features": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optional block-diagram extras. Omit or empty if not shown.",
|
||||||
|
"properties": {
|
||||||
|
"esd_clamp_pins": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"pullup_pins": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"analog_switch": {"type": "array", "items": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"layout_rules": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Optional PCB layout constraints from typical-application pages. kind must be decoupling_proximity, thermal_via, or keepout. max_distance_mm only if the PDF states a number.",
|
||||||
|
"items": {"type": "object"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["component_subtype", "component_subtype_description", "package_info", "pintable"],
|
"required": ["component_subtype", "component_subtype_description", "package_info", "pintable"],
|
||||||
},
|
},
|
||||||
@@ -351,6 +365,25 @@ def _coerce_abs_max(raw: object) -> list[dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_layout_rules(raw: object) -> list[dict]:
|
||||||
|
from backend.pinscopex.layout_rules import validate_layout_rules
|
||||||
|
rows, _errors = validate_layout_rules(raw if isinstance(raw, list) else [])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_internal_features(raw: object):
|
||||||
|
from backend.pinscopex.models import InternalFeatures
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
feat = InternalFeatures.model_validate(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not feat.esd_clamp_pins and not feat.pullup_pins and not feat.analog_switch:
|
||||||
|
return None
|
||||||
|
return feat
|
||||||
|
|
||||||
|
|
||||||
_GENERATE_SPECS_TOOL = {
|
_GENERATE_SPECS_TOOL = {
|
||||||
"name": "save_specs_schema",
|
"name": "save_specs_schema",
|
||||||
"description": "Save the standardized parameter schema for a component type.",
|
"description": "Save the standardized parameter schema for a component type.",
|
||||||
@@ -623,6 +656,8 @@ async def extract_pintable(
|
|||||||
result.get("absolute_maximum_ratings") or [],
|
result.get("absolute_maximum_ratings") or [],
|
||||||
),
|
),
|
||||||
rules=[],
|
rules=[],
|
||||||
|
internal_features=_coerce_internal_features(result.get("internal_features")),
|
||||||
|
layout_rules=_coerce_layout_rules(result.get("layout_rules")),
|
||||||
)
|
)
|
||||||
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ _PAGE_KEYWORDS = re.compile(
|
|||||||
r"|power\s+supply|thermal\s+(resistance|shutdown|pad)|ESD\s+(rating|tolerance)"
|
r"|power\s+supply|thermal\s+(resistance|shutdown|pad)|ESD\s+(rating|tolerance)"
|
||||||
r"|decoupling|bypass\s+capacitor|typical\s+application"
|
r"|decoupling|bypass\s+capacitor|typical\s+application"
|
||||||
r"|application\s+(circuit|schematic|information|note)|reference\s+design"
|
r"|application\s+(circuit|schematic|information|note)|reference\s+design"
|
||||||
|
r"|block\s+diagram|functional\s+block|internal\s+block"
|
||||||
|
r"|pcb\s+layout|layout\s+consideration|thermal\s+via"
|
||||||
r"|ordering\s+information|device\s+information",
|
r"|ordering\s+information|device\s+information",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ from backend.pinscopex.thermal_check import check_thermal
|
|||||||
from backend.pinscopex.power_margin_check import check_power_margin
|
from backend.pinscopex.power_margin_check import check_power_margin
|
||||||
from backend.pinscopex.sequencing_check import check_power_sequencing
|
from backend.pinscopex.sequencing_check import check_power_sequencing
|
||||||
from backend.pinscopex.dnp_check import check_dnp_enables
|
from backend.pinscopex.dnp_check import check_dnp_enables
|
||||||
|
from backend.pinscopex.lifecycle import check_lifecycle, load_lifecycle_dir
|
||||||
|
from backend.pinscopex.errata_check import check_errata
|
||||||
|
from backend.pinscopex.internal_features_check import check_internal_features
|
||||||
|
|
||||||
TRACE_VERSION = 1
|
TRACE_VERSION = 1
|
||||||
|
|
||||||
@@ -67,7 +70,8 @@ def _is_deterministic(f: Finding) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _run_deterministic_checks(
|
def _run_deterministic_checks(
|
||||||
graph: DesignGraph, constraints_map: dict
|
graph: DesignGraph, constraints_map: dict,
|
||||||
|
lifecycle_map: dict | None = None,
|
||||||
) -> list[Finding]:
|
) -> list[Finding]:
|
||||||
"""Run the deterministic graph checks, fail-soft per check — a check bug
|
"""Run the deterministic graph checks, fail-soft per check — a check bug
|
||||||
can never break the review or the report."""
|
can never break the review or the report."""
|
||||||
@@ -87,6 +91,9 @@ def _run_deterministic_checks(
|
|||||||
("power_margin_check", lambda: check_power_margin(graph, constraints_map)),
|
("power_margin_check", lambda: check_power_margin(graph, constraints_map)),
|
||||||
("sequencing_check", lambda: check_power_sequencing(graph, constraints_map)),
|
("sequencing_check", lambda: check_power_sequencing(graph, constraints_map)),
|
||||||
("dnp_check", lambda: check_dnp_enables(graph, constraints_map)),
|
("dnp_check", lambda: check_dnp_enables(graph, constraints_map)),
|
||||||
|
("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)),
|
||||||
|
("errata_check", lambda: check_errata(graph, constraints_map)),
|
||||||
|
("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
out.extend(fn())
|
out.extend(fn())
|
||||||
@@ -693,10 +700,17 @@ async def validate_design_async(
|
|||||||
graph = DesignGraph.model_validate(raw)
|
graph = DesignGraph.model_validate(raw)
|
||||||
datasheets = _load_datasheets(datasheets_dir)
|
datasheets = _load_datasheets(datasheets_dir)
|
||||||
constraints_map = _build_constraints_map(datasheets)
|
constraints_map = _build_constraints_map(datasheets)
|
||||||
|
lifecycle_map = {}
|
||||||
# Deterministic graph checks (pin-mux feasibility, LED current). Pure
|
for cand in (
|
||||||
# functions of the graph; fail-soft. Seeded into all_findings below.
|
Path(datasheets_dir).parent / "lifecycle",
|
||||||
deterministic_findings = _run_deterministic_checks(graph, constraints_map)
|
Path(datasheets_dir) / "lifecycle",
|
||||||
|
):
|
||||||
|
loaded = load_lifecycle_dir(cand)
|
||||||
|
if loaded:
|
||||||
|
lifecycle_map.update(loaded)
|
||||||
|
deterministic_findings = _run_deterministic_checks(
|
||||||
|
graph, constraints_map, lifecycle_map,
|
||||||
|
)
|
||||||
|
|
||||||
pdf_dir_path = Path(pdf_dir)
|
pdf_dir_path = Path(pdf_dir)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
What's new in Pinscope.
|
||||||
|
|
||||||
|
## 2.17.0 — 2026-09-10 — Lifecycle and datasheet extras
|
||||||
|
|
||||||
|
Distributor lifecycle is a cached check, not a review scrape. Errata and layout_rules stay structured and skip when the catalog or the PDF has no number.
|
||||||
|
|
||||||
|
- [New] `PS-LF-001` EOL, `PS-LF-002` NRND, `PS-LF-003` explicit RoHS fail. Replacement only if the distributor lists it. Active / RoHS N/A / missing cache row are silent.
|
||||||
|
- [New] `PS-ERRATA-001` when a catalogued workaround pull-up is missing. No URL → skip.
|
||||||
|
- [New] `PS-INT-001` when `internal_features.pullup_pins` has no rail resistor.
|
||||||
|
- [New] `layout_rules` closed kinds; non-numeric `max_distance_mm` is stored as null.
|
||||||
|
|
||||||
## 2.16.0 — 2026-09-10 — KiCad cad-bridge
|
## 2.16.0 — 2026-09-10 — KiCad cad-bridge
|
||||||
|
|
||||||
Pinscope writes `pinscope-findings.json` next to the report so a KiCad 9/10 action plugin can pan to the symbol uuid on the right sheet.
|
Pinscope writes `pinscope-findings.json` next to the report so a KiCad 9/10 action plugin can pan to the symbol uuid on the right sheet.
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ Rules for pin extraction:
|
|||||||
- Include ALL pins — power, ground, NC, and signal pins
|
- Include ALL pins — power, ground, NC, and signal pins
|
||||||
- Use pin names verbatim from the datasheet — do not rename or normalize
|
- Use pin names verbatim from the datasheet — do not rename or normalize
|
||||||
- For multiplexed pins, put the primary name in `name` and alternates in `functions`
|
- For multiplexed pins, put the primary name in `name` and alternates in `functions`
|
||||||
|
|
||||||
|
Optional extras (omit if the PDF does not show them):
|
||||||
|
- `internal_features.pullup_pins` / `esd_clamp_pins` / `analog_switch` from the **block diagram** only.
|
||||||
|
- `layout_rules` from **PCB layout / typical application** pages. `kind` is only `decoupling_proximity`, `thermal_via`, or `keepout`. Set `max_distance_mm` only when the document states a number — do not invent JEDEC millimetres.
|
||||||
|
|
||||||
|
Rules for pin extraction:`
|
||||||
- If the datasheet has separate tables for different packages, extract for the package matching the MPN
|
- If the datasheet has separate tables for different packages, extract for the package matching the MPN
|
||||||
- Pay careful attention to pin numbering — off-by-one errors here break everything downstream
|
- Pay careful attention to pin numbering — off-by-one errors here break everything downstream
|
||||||
- **Modules vs bare die (critical).** MPNs containing `WROOM`, `WROVER`, `MODULE`, `MOD-`, or `SIP` are *modules*. Extract the **module landing-pad table** (connector pins the schematic uses). Do **not** extract the SoC/QFN ball map from a nested chip chapter or a sibling chip-only PDF.
|
- **Modules vs bare die (critical).** MPNs containing `WROOM`, `WROVER`, `MODULE`, `MOD-`, or `SIP` are *modules*. Extract the **module landing-pad table** (connector pins the schematic uses). Do **not** extract the SoC/QFN ball map from a nested chip chapter or a sibling chip-only PDF.
|
||||||
|
|||||||
@@ -45,6 +45,18 @@
|
|||||||
},
|
},
|
||||||
"required": ["parameter", "unit", "source_page"]
|
"required": ["parameter", "unit", "source_page"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"internal_features": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"esd_clamp_pins": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"pullup_pins": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"analog_switch": {"type": "array", "items": {"type": "string"}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layout_rules": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "object"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["component_subtype", "package_info", "pintable"]
|
"required": ["component_subtype", "package_info", "pintable"]
|
||||||
|
|||||||
@@ -81,6 +81,19 @@ def validate(data: dict) -> list[str]:
|
|||||||
f"absolute_maximum_ratings[{i}] missing required field: {f}"
|
f"absolute_maximum_ratings[{i}] missing required field: {f}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "layout_rules" in data and data["layout_rules"] is not None:
|
||||||
|
if not isinstance(data["layout_rules"], list):
|
||||||
|
errors.append("layout_rules must be an array")
|
||||||
|
else:
|
||||||
|
kinds = {"decoupling_proximity", "thermal_via", "keepout"}
|
||||||
|
for i, row in enumerate(data["layout_rules"]):
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
errors.append(f"layout_rules[{i}] must be an object")
|
||||||
|
continue
|
||||||
|
kind = row.get("kind")
|
||||||
|
if kind not in kinds:
|
||||||
|
errors.append(f"layout_rules[{i}] unknown kind: {kind!r}")
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Errata catalog — workaround on the graph, no scraping.
|
||||||
|
|
||||||
|
Favor: known MPN with a pull-up workaround missing on the net → PS-ERRATA-001.
|
||||||
|
Against: MPN not in catalog is silent (even TI-looking); workaround pull-up
|
||||||
|
present is silent; catalog entry without url is skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.pinscopex.errata_check import check_errata
|
||||||
|
from backend.pinscopex.models import (
|
||||||
|
Component,
|
||||||
|
ComponentConstraints,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
Net,
|
||||||
|
NetType,
|
||||||
|
Pin,
|
||||||
|
PinConnection,
|
||||||
|
ResistorSpecs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cons():
|
||||||
|
return {
|
||||||
|
"ERRX": ComponentConstraints(
|
||||||
|
mpn="ERRX",
|
||||||
|
pintable=[Pin(number=1, name="NRST"), Pin(number=2, name="GND")],
|
||||||
|
absolute_maximum_ratings=[], rules=[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _graph(with_pull: bool):
|
||||||
|
u = Component(
|
||||||
|
reference="U1", value="", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="ERRX",
|
||||||
|
pins={"1": "NRST", "2": "GND"},
|
||||||
|
)
|
||||||
|
comps = {"U1": u}
|
||||||
|
nets = {
|
||||||
|
"NRST": (NetType.SIGNAL, [("U1", "1")]),
|
||||||
|
"GND": (NetType.GROUND, [("U1", "2")]),
|
||||||
|
"3V3": (NetType.POWER, []),
|
||||||
|
}
|
||||||
|
if with_pull:
|
||||||
|
r = Component(
|
||||||
|
reference="R1", value="10k", footprint="",
|
||||||
|
component_type=ComponentType.RESISTOR, mpn="R1",
|
||||||
|
pins={"1": "NRST", "2": "3V3"},
|
||||||
|
specs=ResistorSpecs(value_ohms=10000, value_formatted="10k"),
|
||||||
|
)
|
||||||
|
comps["R1"] = r
|
||||||
|
nets["NRST"] = (NetType.SIGNAL, [("U1", "1"), ("R1", "1")])
|
||||||
|
nets["3V3"] = (NetType.POWER, [("R1", "2")])
|
||||||
|
net_objs = {
|
||||||
|
name: Net(
|
||||||
|
name=name, net_type=ntype,
|
||||||
|
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
|
||||||
|
)
|
||||||
|
for name, (ntype, conns) in nets.items()
|
||||||
|
}
|
||||||
|
return DesignGraph(components=comps, nets=net_objs)
|
||||||
|
|
||||||
|
|
||||||
|
CATALOG = {
|
||||||
|
"ERRX": {
|
||||||
|
"url": "https://www.ti.com/lit/er/fixture",
|
||||||
|
"workarounds": [
|
||||||
|
{"kind": "pullup", "pin_name": "NRST", "note": "10 kΩ to VDD per errata"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_errata_pullup_is_ps_errata_001():
|
||||||
|
findings = check_errata(_graph(False), _cons(), CATALOG)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].rule_id == "PS-ERRATA-001"
|
||||||
|
assert findings[0].status == "WARNING"
|
||||||
|
assert findings[0].source == "errata_check"
|
||||||
|
assert "ti.com/lit/er" in findings[0].reference
|
||||||
|
|
||||||
|
|
||||||
|
def test_pullup_present_is_silent():
|
||||||
|
assert check_errata(_graph(True), _cons(), CATALOG) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_mpn_and_url_less_entry_are_silent():
|
||||||
|
g = _graph(False)
|
||||||
|
g.components["U1"].mpn = "UNKNOWNPART"
|
||||||
|
assert check_errata(g, _cons(), CATALOG) == []
|
||||||
|
no_url = {"ERRX": {"url": "", "workarounds": [{"kind": "pullup", "pin_name": "NRST"}]}}
|
||||||
|
assert check_errata(_graph(False), _cons(), no_url) == []
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Internal features (block-diagram extraction) — open-drain pull-up.
|
||||||
|
|
||||||
|
Favor: pin listed in pullup_pins with no resistor to a rail → PS-INT-001.
|
||||||
|
Against: empty internal_features is silent; listed pin with a pull-up is
|
||||||
|
silent; a pin not in pullup_pins is not guessed as open-drain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.pinscopex.internal_features_check import check_internal_features
|
||||||
|
from backend.pinscopex.models import (
|
||||||
|
Component,
|
||||||
|
ComponentConstraints,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
InternalFeatures,
|
||||||
|
Net,
|
||||||
|
NetType,
|
||||||
|
Pin,
|
||||||
|
PinConnection,
|
||||||
|
ResistorSpecs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _graph(with_pull: bool):
|
||||||
|
u = Component(
|
||||||
|
reference="U1", value="", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="UTEST",
|
||||||
|
pins={"1": "SDA", "2": "GND"},
|
||||||
|
)
|
||||||
|
comps = {"U1": u}
|
||||||
|
nets = {
|
||||||
|
"SDA": (NetType.SIGNAL, [("U1", "1")]),
|
||||||
|
"GND": (NetType.GROUND, [("U1", "2")]),
|
||||||
|
"3V3": (NetType.POWER, []),
|
||||||
|
}
|
||||||
|
if with_pull:
|
||||||
|
r = Component(
|
||||||
|
reference="R1", value="4k7", footprint="",
|
||||||
|
component_type=ComponentType.RESISTOR, mpn="R1",
|
||||||
|
pins={"1": "SDA", "2": "3V3"},
|
||||||
|
specs=ResistorSpecs(value_ohms=4700, value_formatted="4k7"),
|
||||||
|
)
|
||||||
|
comps["R1"] = r
|
||||||
|
nets["SDA"] = (NetType.SIGNAL, [("U1", "1"), ("R1", "1")])
|
||||||
|
nets["3V3"] = (NetType.POWER, [("R1", "2")])
|
||||||
|
net_objs = {
|
||||||
|
name: Net(
|
||||||
|
name=name, net_type=ntype,
|
||||||
|
pins=[PinConnection(component_ref=a, pin_number=str(b)) for a, b in conns],
|
||||||
|
)
|
||||||
|
for name, (ntype, conns) in nets.items()
|
||||||
|
}
|
||||||
|
return DesignGraph(components=comps, nets=net_objs)
|
||||||
|
|
||||||
|
|
||||||
|
def _cons(features: InternalFeatures | None):
|
||||||
|
return {
|
||||||
|
"UTEST": ComponentConstraints(
|
||||||
|
mpn="UTEST",
|
||||||
|
pintable=[Pin(number=1, name="SDA"), Pin(number=2, name="GND")],
|
||||||
|
absolute_maximum_ratings=[], rules=[],
|
||||||
|
internal_features=features,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_listed_open_drain_without_pull_is_warning():
|
||||||
|
feats = InternalFeatures(pullup_pins=["SDA"])
|
||||||
|
findings = check_internal_features(_graph(False), _cons(feats))
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].rule_id == "PS-INT-001"
|
||||||
|
assert findings[0].status == "WARNING"
|
||||||
|
|
||||||
|
|
||||||
|
def test_listed_pin_with_pullup_is_silent():
|
||||||
|
feats = InternalFeatures(pullup_pins=["SDA"])
|
||||||
|
assert check_internal_features(_graph(True), _cons(feats)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_features_does_not_guess_open_drain():
|
||||||
|
assert check_internal_features(_graph(False), _cons(None)) == []
|
||||||
|
assert check_internal_features(_graph(False), _cons(InternalFeatures())) == []
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""layout_rules from datasheet — closed kind enum, no invented millimetres.
|
||||||
|
|
||||||
|
Favor: decoupling_proximity with a numeric max_distance_mm and source_page;
|
||||||
|
empty list is valid (explicit skip).
|
||||||
|
Against: unknown kind is rejected; a non-numeric distance becomes null
|
||||||
|
(not a guessed JEDEC 3 mm); thermal_via without min_via_count is kept
|
||||||
|
but distance stays unset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.pinscopex.layout_rules import validate_layout_rules
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_decoupling_proximity_keeps_distance():
|
||||||
|
ok, errors = validate_layout_rules([
|
||||||
|
{
|
||||||
|
"kind": "decoupling_proximity",
|
||||||
|
"pin": "VDD",
|
||||||
|
"cap_value_hint": "100nF",
|
||||||
|
"max_distance_mm": 2.0,
|
||||||
|
"same_layer": True,
|
||||||
|
"source_page": 14,
|
||||||
|
}
|
||||||
|
])
|
||||||
|
assert errors == []
|
||||||
|
assert len(ok) == 1
|
||||||
|
assert ok[0]["max_distance_mm"] == 2.0
|
||||||
|
assert ok[0]["kind"] == "decoupling_proximity"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list_is_explicit_skip():
|
||||||
|
ok, errors = validate_layout_rules([])
|
||||||
|
assert ok == []
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_kind_rejected_and_bad_distance_not_invented():
|
||||||
|
ok, errors = validate_layout_rules([
|
||||||
|
{"kind": "jedec_land_pattern", "max_distance_mm": 3.0},
|
||||||
|
{
|
||||||
|
"kind": "decoupling_proximity",
|
||||||
|
"pin": "VDD",
|
||||||
|
"max_distance_mm": "close",
|
||||||
|
"source_page": 2,
|
||||||
|
},
|
||||||
|
{"kind": "thermal_via", "pin": "EP", "min_via_count": 4},
|
||||||
|
])
|
||||||
|
assert any("kind" in e.lower() or "jedec" in e.lower() for e in errors)
|
||||||
|
dist_rows = [r for r in ok if r["kind"] == "decoupling_proximity"]
|
||||||
|
assert len(dist_rows) == 1
|
||||||
|
assert dist_rows[0]["max_distance_mm"] is None
|
||||||
|
via = [r for r in ok if r["kind"] == "thermal_via"]
|
||||||
|
assert len(via) == 1 and via[0]["min_via_count"] == 4
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Lifecycle from distributor payload.
|
||||||
|
|
||||||
|
Favor: DigiKey Obsolete → PS-LF-001 WARNING and uses ProductSubstitutions;
|
||||||
|
NRND → PS-LF-002 INFO; explicit RoHS Non-Compliant → PS-LF-003.
|
||||||
|
Against: Active is silent; RoHS Not Applicable is not a fail; missing
|
||||||
|
catalog row is silent; no substitution key means no invented replacement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.pinscopex.lifecycle import (
|
||||||
|
check_lifecycle,
|
||||||
|
parse_distributor_product,
|
||||||
|
)
|
||||||
|
from backend.pinscopex.models import Component, ComponentType, DesignGraph, Net, NetType, PinConnection
|
||||||
|
|
||||||
|
|
||||||
|
def _graph(mpn="PARTX"):
|
||||||
|
u = Component(
|
||||||
|
reference="U1", value="", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn=mpn,
|
||||||
|
pins={"1": "VDD"},
|
||||||
|
)
|
||||||
|
return DesignGraph(
|
||||||
|
components={"U1": u},
|
||||||
|
nets={"VDD": Net(name="VDD", net_type=NetType.POWER, pins=[
|
||||||
|
PinConnection(component_ref="U1", pin_number="1"),
|
||||||
|
])},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_obsolete_is_warning_and_uses_distributor_replacement():
|
||||||
|
rec = parse_distributor_product("ABC", {
|
||||||
|
"ManufacturerProductNumber": "ABC",
|
||||||
|
"ProductStatus": "Obsolete",
|
||||||
|
"RoHSStatus": "RoHS3 Compliant",
|
||||||
|
"QuantityAvailable": 12,
|
||||||
|
"ManufacturerLeadWeeks": "8",
|
||||||
|
"ProductSubstitutions": [{"ManufacturerProductNumber": "ABC-B"}],
|
||||||
|
})
|
||||||
|
assert rec.lifecycle == "eol"
|
||||||
|
assert rec.rohs_compliant is True
|
||||||
|
assert rec.stock == 12
|
||||||
|
assert rec.replacement == "ABC-B"
|
||||||
|
findings = check_lifecycle(_graph("ABC"), {"ABC": rec})
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].rule_id == "PS-LF-001"
|
||||||
|
assert findings[0].status == "WARNING"
|
||||||
|
assert findings[0].source == "lifecycle_check"
|
||||||
|
assert "ABC-B" in (findings[0].recommendation or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_nrnd_is_info():
|
||||||
|
rec = parse_distributor_product("N1", {"ProductStatus": "Not For New Designs"})
|
||||||
|
assert rec.lifecycle == "nrnd"
|
||||||
|
findings = check_lifecycle(_graph("N1"), {"N1": rec})
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].rule_id == "PS-LF-002"
|
||||||
|
assert findings[0].status == "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_rohs_non_compliant_is_warning():
|
||||||
|
rec = parse_distributor_product("R1", {
|
||||||
|
"ProductStatus": "Active",
|
||||||
|
"RoHSStatus": "Non-Compliant",
|
||||||
|
})
|
||||||
|
assert rec.rohs_compliant is False
|
||||||
|
findings = check_lifecycle(_graph("R1"), {"R1": rec})
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].rule_id == "PS-LF-003"
|
||||||
|
assert findings[0].status == "WARNING"
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_is_silent():
|
||||||
|
rec = parse_distributor_product("A1", {"ProductStatus": "Active"})
|
||||||
|
assert rec.lifecycle == "active"
|
||||||
|
assert check_lifecycle(_graph("A1"), {"A1": rec}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_rohs_not_applicable_is_not_a_fail():
|
||||||
|
rec = parse_distributor_product("R2", {
|
||||||
|
"ProductStatus": "Active",
|
||||||
|
"RoHSStatus": "Not Applicable",
|
||||||
|
})
|
||||||
|
assert rec.rohs_compliant is None
|
||||||
|
assert check_lifecycle(_graph("R2"), {"R2": rec}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_catalog_and_missing_substitute_are_not_guessed():
|
||||||
|
assert check_lifecycle(_graph("NOPE"), {}) == []
|
||||||
|
rec = parse_distributor_product("EOLX", {"ProductStatus": "Discontinued"})
|
||||||
|
assert rec.lifecycle == "eol"
|
||||||
|
assert rec.replacement is None
|
||||||
|
findings = check_lifecycle(_graph("EOLX"), {"EOLX": rec})
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].rule_id == "PS-LF-001"
|
||||||
|
assert "ABC-B" not in (findings[0].recommendation or "")
|
||||||
|
assert rec.replacement is None
|
||||||
Reference in New Issue
Block a user