diff --git a/backend/pinscopex/errata_check.py b/backend/pinscopex/errata_check.py new file mode 100644 index 0000000..186d402 --- /dev/null +++ b/backend/pinscopex/errata_check.py @@ -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)) diff --git a/backend/pinscopex/eval_report.py b/backend/pinscopex/eval_report.py index 45909b7..aca873a 100644 --- a/backend/pinscopex/eval_report.py +++ b/backend/pinscopex/eval_report.py @@ -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.sequencing_check import check_power_sequencing 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): @@ -93,6 +96,9 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]: out.extend(check_power_margin(graph, cmap)) out.extend(check_power_sequencing(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 diff --git a/backend/pinscopex/internal_features_check.py b/backend/pinscopex/internal_features_check.py new file mode 100644 index 0000000..d26318e --- /dev/null +++ b/backend/pinscopex/internal_features_check.py @@ -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 diff --git a/backend/pinscopex/layout_rules.py b/backend/pinscopex/layout_rules.py new file mode 100644 index 0000000..bab4093 --- /dev/null +++ b/backend/pinscopex/layout_rules.py @@ -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 diff --git a/backend/pinscopex/lifecycle.py b/backend/pinscopex/lifecycle.py new file mode 100644 index 0000000..783f5f6 --- /dev/null +++ b/backend/pinscopex/lifecycle.py @@ -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 diff --git a/backend/pinscopex/models.py b/backend/pinscopex/models.py index 0f7c83c..a665618 100644 --- a/backend/pinscopex/models.py +++ b/backend/pinscopex/models.py @@ -44,6 +44,13 @@ def _check_subtype(v: object) -> str | None: 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): mpn: str 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] absolute_maximum_ratings: list[AbsMaxRating] rules: list[Rule] + internal_features: InternalFeatures | None = None + layout_rules: list[dict] = [] _validate_subtype = field_validator("component_subtype", mode="before")( staticmethod(_check_subtype) diff --git a/backend/services/extraction.py b/backend/services/extraction.py index df5c751..1bb9cd3 100644 --- a/backend/services/extraction.py +++ b/backend/services/extraction.py @@ -125,6 +125,20 @@ PINTABLE_TOOL = { "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"], }, @@ -351,6 +365,25 @@ def _coerce_abs_max(raw: object) -> list[dict]: 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 = { "name": "save_specs_schema", "description": "Save the standardized parameter schema for a component type.", @@ -623,6 +656,8 @@ async def extract_pintable( result.get("absolute_maximum_ratings") or [], ), 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) diff --git a/backend/services/llm/pdf_ingest.py b/backend/services/llm/pdf_ingest.py index 2165427..52766ab 100644 --- a/backend/services/llm/pdf_ingest.py +++ b/backend/services/llm/pdf_ingest.py @@ -35,6 +35,8 @@ _PAGE_KEYWORDS = re.compile( r"|power\s+supply|thermal\s+(resistance|shutdown|pad)|ESD\s+(rating|tolerance)" r"|decoupling|bypass\s+capacitor|typical\s+application" 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", re.IGNORECASE, ) diff --git a/backend/services/validation.py b/backend/services/validation.py index ae2c76a..3c39418 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -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.sequencing_check import check_power_sequencing 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 @@ -67,7 +70,8 @@ def _is_deterministic(f: Finding) -> bool: def _run_deterministic_checks( - graph: DesignGraph, constraints_map: dict + graph: DesignGraph, constraints_map: dict, + lifecycle_map: dict | None = None, ) -> list[Finding]: """Run the deterministic graph checks, fail-soft per check — a check bug 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)), ("sequencing_check", lambda: check_power_sequencing(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: out.extend(fn()) @@ -693,10 +700,17 @@ async def validate_design_async( graph = DesignGraph.model_validate(raw) datasheets = _load_datasheets(datasheets_dir) constraints_map = _build_constraints_map(datasheets) - - # Deterministic graph checks (pin-mux feasibility, LED current). Pure - # functions of the graph; fail-soft. Seeded into all_findings below. - deterministic_findings = _run_deterministic_checks(graph, constraints_map) + lifecycle_map = {} + for cand in ( + Path(datasheets_dir).parent / "lifecycle", + 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) diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index f2f5879..5fe0a76 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,15 @@ 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 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. diff --git a/skills/extract-pintable/SKILL.md b/skills/extract-pintable/SKILL.md index 8d5939f..a932dfc 100644 --- a/skills/extract-pintable/SKILL.md +++ b/skills/extract-pintable/SKILL.md @@ -28,6 +28,12 @@ Rules for pin extraction: - Include ALL pins — power, ground, NC, and signal pins - 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` + +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 - 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. diff --git a/skills/extract-pintable/schema.json b/skills/extract-pintable/schema.json index 2173af8..9c338c3 100644 --- a/skills/extract-pintable/schema.json +++ b/skills/extract-pintable/schema.json @@ -45,6 +45,18 @@ }, "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"] diff --git a/skills/extract-pintable/validate.py b/skills/extract-pintable/validate.py index 7d1f186..c0532bc 100644 --- a/skills/extract-pintable/validate.py +++ b/skills/extract-pintable/validate.py @@ -81,6 +81,19 @@ def validate(data: dict) -> list[str]: 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 diff --git a/tests/test_errata_check.py b/tests/test_errata_check.py new file mode 100644 index 0000000..1cf4892 --- /dev/null +++ b/tests/test_errata_check.py @@ -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) == [] diff --git a/tests/test_internal_features_check.py b/tests/test_internal_features_check.py new file mode 100644 index 0000000..a68e763 --- /dev/null +++ b/tests/test_internal_features_check.py @@ -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())) == [] diff --git a/tests/test_layout_rules.py b/tests/test_layout_rules.py new file mode 100644 index 0000000..e03faa1 --- /dev/null +++ b/tests/test_layout_rules.py @@ -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 diff --git a/tests/test_lifecycle_check.py b/tests/test_lifecycle_check.py new file mode 100644 index 0000000..f60bada --- /dev/null +++ b/tests/test_lifecycle_check.py @@ -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