From a45a06e7610a402efb8563a7a5424753bd5bf7e1 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Thu, 10 Sep 2026 22:28:40 +0200 Subject: [PATCH] Add schematic filter topology and LDO/resistor thermal checks. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match RC/LC/π/T without inventing fc, compare to ADC rate only when specs list it, and skip ferrite DCR unless the IC gives a limit. LDO Tj uses I_load not Iout_max, and missing θJA stays INFO. Co-authored-by: Cursor --- backend/pinscopex/eval_report.py | 4 + backend/pinscopex/filter_check.py | 384 +++++++++++++++++++++++++++++ backend/pinscopex/models.py | 6 +- backend/pinscopex/thermal_check.py | 294 ++++++++++++++++++++++ backend/services/validation.py | 4 + frontend/content/changelog.md | 8 + tests/test_filter_check.py | 230 +++++++++++++++++ tests/test_thermal_check.py | 144 +++++++++++ 8 files changed, 1070 insertions(+), 4 deletions(-) create mode 100644 backend/pinscopex/filter_check.py create mode 100644 backend/pinscopex/thermal_check.py create mode 100644 tests/test_filter_check.py create mode 100644 tests/test_thermal_check.py diff --git a/backend/pinscopex/eval_report.py b/backend/pinscopex/eval_report.py index 8c47c89..1001d3e 100644 --- a/backend/pinscopex/eval_report.py +++ b/backend/pinscopex/eval_report.py @@ -22,6 +22,8 @@ from backend.pinscopex.passive_rail_check import ( ) from backend.pinscopex.bom_match_check import check_bom_schematic_match from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage +from backend.pinscopex.filter_check import check_filters +from backend.pinscopex.thermal_check import check_thermal class EvalScores(BaseModel): @@ -83,6 +85,8 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]: out.extend(check_reset_pullups(graph, cmap)) out.extend(check_bom_schematic_match(graph.schematic_fields, graph.bom_fields)) out.extend(check_hf_decoupling_coverage(graph, cmap)) + out.extend(check_filters(graph, cmap)) + out.extend(check_thermal(graph, cmap)) return out diff --git a/backend/pinscopex/filter_check.py b/backend/pinscopex/filter_check.py new file mode 100644 index 0000000..d167c1a --- /dev/null +++ b/backend/pinscopex/filter_check.py @@ -0,0 +1,384 @@ +"""Signal-filter topology: RC, LC, ferrite+C, π (C-L-C), T (L-C-L). + +fc is reported only when R/L/C values are known. Sample-rate comparison +and ferrite DCR limits fire only when the neighboring IC specs list them. +Power-rail decoupling is not a signal filter. +""" + +from __future__ import annotations + +import math +import re + +from backend.pinscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, + InductorSpecs, +) +from backend.pinscopex.passive_rail_check import ( + _cap_farads, + _is_ground_net, + _is_power_net, + _pin_name_tokens, + _resistor_ohms, +) +from backend.pinscopex.validate import _match_constraints + +_ADC_RATE_KEYS = ("adc_sample_rate", "adc_sample_rate_hz", "data_rate", "data_rate_hz") +_DCR_MAX_KEYS = ("max_ferrite_dcr_ohms", "ferrite_dcr_max_ohms", "max_bead_dcr_ohms") +_ANALOG_RE = re.compile( + r"(?:^|[_/])(ADC|AIN|VDDA|AVDD|VREF)(?:$|[_/\d])", + re.IGNORECASE, +) + + +def _inductor_henries(comp: Component) -> float | None: + specs = comp.specs + if isinstance(specs, InductorSpecs) and specs.value_henries: + return float(specs.value_henries) + return None + + +def _dcr_ohms(comp: Component) -> float | None: + specs = comp.specs + if isinstance(specs, InductorSpecs) and specs.dcr_ohms is not None: + return float(specs.dcr_ohms) + return None + + +def _is_ferrite(comp: Component) -> bool: + sub = (comp.component_subtype or "").lower() + if "ferrite" in sub: + return True + specs = comp.specs + if isinstance(specs, InductorSpecs) and specs.component_subtype: + return "ferrite" in specs.component_subtype + return comp.reference.upper().startswith("FB") + + +def _two_nets(comp: Component) -> tuple[str, str] | None: + nets = list(dict.fromkeys(comp.pins.values())) + if len(nets) != 2: + return None + return nets[0], nets[1] + + +def _gnd_caps(graph: DesignGraph, net: str) -> list[tuple[str, float | None]]: + out: list[tuple[str, float | None]] = [] + for ref in graph.capacitors_on_net(net): + cap = graph.components[ref] + others = {n for n in cap.pins.values() if n != net} + if any(_is_ground_net(graph, n) for n in others): + out.append((ref, _cap_farads(cap))) + return out + + +def _sum_known_c(caps: list[tuple[str, float | None]]) -> float | None: + vals = [c for _, c in caps if c is not None] + if not vals or len(vals) != len(caps): + return None + return sum(vals) + + +def _fc_rc(r: float, c: float) -> float: + return 1.0 / (2.0 * math.pi * r * c) + + +def _fc_lc(l: float, c: float) -> float: + return 1.0 / (2.0 * math.pi * math.sqrt(l * c)) + + +def _ic_specs_values(comp: Component) -> dict: + specs = comp.specs + values = getattr(specs, "values", None) if specs else None + return values if isinstance(values, dict) else {} + + +def _adc_rate_hz(graph: DesignGraph, ic_refs: list[str]) -> float | None: + for ref in ic_refs: + values = _ic_specs_values(graph.components[ref]) + for key in _ADC_RATE_KEYS: + raw = values.get(key) + if raw is None: + continue + try: + return float(raw) + except (TypeError, ValueError): + continue + return None + + +def _dcr_limit_ohms(graph: DesignGraph, ic_refs: list[str]) -> float | None: + for ref in ic_refs: + values = _ic_specs_values(graph.components[ref]) + for key in _DCR_MAX_KEYS: + raw = values.get(key) + if raw is None: + continue + try: + return float(raw) + except (TypeError, ValueError): + continue + return None + + +def _ic_refs_on(graph: DesignGraph, *nets: str) -> list[str]: + refs: list[str] = [] + for net in nets: + for r in graph.components_on_net(net): + c = graph.components.get(r) + if c and c.component_type == ComponentType.IC and r not in refs: + refs.append(r) + return refs + + +def _analog_net( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints], + *nets: str, +) -> str | None: + for net in nets: + if _ANALOG_RE.search(net or ""): + return net + for ref in _ic_refs_on(graph, net): + cons = _match_constraints(graph.components[ref].mpn or "", constraints_map) + for pin_num, pin_net in graph.components[ref].pins.items(): + if pin_net != net: + continue + if _ANALOG_RE.search(net): + return net + for tok in _pin_name_tokens(cons, pin_num): + if _ANALOG_RE.search(tok): + return net + return None + + +def _filter_finding( + *, + kind: str, + fc: float | None, + designator: str, + mpn: str, + net: str, + extra_why: str, + adc_hz: float | None, +) -> Finding: + if fc is None: + return Finding( + designator=designator, + mpn=mpn, + aspect="filter", + source="filter_check", + status="INFO", + finding=f"{kind} filter on '{net}' ({designator}); fc unknown (missing L/C/R value).", + why=extra_why, + recommendation="Populate passive values to compute cutoff.", + reference="netlist topology", + net=net, + pins=[designator], + rule_id="PS-FLT-001", + ) + if adc_hz is not None and not (0.1 * adc_hz <= fc <= 20 * adc_hz): + return Finding( + designator=designator, + mpn=mpn, + aspect="filter", + source="filter_check", + status="WARNING", + finding=( + f"{kind} filter on '{net}' has fc ≈ {fc:.3g} Hz vs ADC/data rate " + f"{adc_hz:.3g} Hz." + ), + why=extra_why + " Compared only because the IC specs list a sample/data rate.", + recommendation="Adjust R/C (or L) so fc sits nearer the sample rate, or confirm anti-alias intent.", + reference="netlist topology", + net=net, + pins=[designator], + rule_id="PS-FLT-002", + ) + rec = ( + "fc is within a wide band of the IC sample/data rate." + if adc_hz is not None + else "Verify fc against the analog bandwidth; no datasheet rate was present." + ) + return Finding( + designator=designator, + mpn=mpn, + aspect="filter", + source="filter_check", + status="INFO", + finding=f"{kind} filter on '{net}' ({designator}), fc ≈ {fc:.3g} Hz.", + why=extra_why, + recommendation=rec, + reference="netlist topology", + net=net, + pins=[designator], + rule_id="PS-FLT-001", + ) + + +def _emit( + findings: list[Finding], + seen: set[tuple[str, str]], + *, + kind: str, + ref: str, + net: str, + fc: float | None, + mpn: str, + extra_why: str, + adc_hz: float | None, +) -> None: + key = (kind, ref) + if key in seen: + return + seen.add(key) + findings.append(_filter_finding( + kind=kind, fc=fc, designator=ref, mpn=mpn, net=net, + extra_why=extra_why, adc_hz=adc_hz, + )) + + +def check_filters( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints] | None = None, +) -> list[Finding]: + cmap = constraints_map or {} + findings: list[Finding] = [] + seen: set[tuple[str, str]] = set() + used_l: set[str] = set() + + # T: two series L sharing a middle net that has C to GND. + for mid in sorted(graph.nets): + if _is_ground_net(graph, mid): + continue + caps = _gnd_caps(graph, mid) + if not caps: + continue + inds = [ + r for r in graph.components_on_net(mid) + if (c := graph.components.get(r)) is not None + and c.component_type == ComponentType.INDUCTOR + ] + if len(inds) != 2: + continue + ends: list[str] = [] + ok = True + for r in inds: + pair = _two_nets(graph.components[r]) + if not pair: + ok = False + break + other = pair[1] if pair[0] == mid else pair[0] + if _is_ground_net(graph, other): + ok = False + break + ends.append(other) + if not ok: + continue + lvals = [_inductor_henries(graph.components[r]) for r in inds] + c_f = _sum_known_c(caps) + l_eq = sum(lvals) if all(lvals) else None # type: ignore[arg-type] + fc = _fc_lc(l_eq, c_f) if l_eq and c_f else None + ics = _ic_refs_on(graph, mid, *ends) + _emit( + findings, seen, kind="T", ref="+".join(sorted(inds)), net=mid, + fc=fc, mpn=graph.components[inds[0]].mpn or "", + extra_why="T network (L-C-L).", + adc_hz=_adc_rate_hz(graph, ics), + ) + used_l.update(inds) + + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.INDUCTOR or ref in used_l: + continue + pair = _two_nets(comp) + if not pair: + continue + n1, n2 = pair + if _is_ground_net(graph, n1) or _is_ground_net(graph, n2): + continue + c1, c2 = _gnd_caps(graph, n1), _gnd_caps(graph, n2) + ferrite = _is_ferrite(comp) + lval = _inductor_henries(comp) + ics = _ic_refs_on(graph, n1, n2) + adc = _adc_rate_hz(graph, ics) + if c1 and c2: + if _is_power_net(graph, n1) and _is_power_net(graph, n2) and not ferrite: + continue + s1, s2 = _sum_known_c(c1), _sum_known_c(c2) + c_eq = None + if s1 and s2: + c_eq = 1.0 / (1.0 / s1 + 1.0 / s2) + fc = _fc_lc(lval, c_eq) if lval and c_eq else None + _emit( + findings, seen, kind="π", ref=ref, net=n1, fc=fc, + mpn=comp.mpn or "", extra_why="π network (C-L-C).", adc_hz=adc, + ) + elif c1 or c2: + filt_net = n1 if c1 else n2 + if _is_power_net(graph, filt_net) and not ferrite: + continue + caps = c1 or c2 + c_f = _sum_known_c(caps) + fc = _fc_lc(lval, c_f) if lval and c_f else None + kind = "ferrite+C" if ferrite else "LC" + _emit( + findings, seen, kind=kind, ref=ref, net=filt_net, fc=fc, + mpn=comp.mpn or "", + extra_why="Series L/ferrite with shunt C to ground.", + adc_hz=adc, + ) + analog = _analog_net(graph, cmap, n1, n2) + limit = _dcr_limit_ohms(graph, ics) + dcr = _dcr_ohms(comp) + if ferrite and analog and limit is not None and dcr is not None and dcr > limit: + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="filter", + source="filter_check", + status="WARNING", + finding=( + f"{ref} ferrite DCR {dcr:.3g} Ω on analog net '{analog}' " + f"exceeds {limit:.3g} Ω." + ), + why="Bead DCR vs the IC spec limit on an analog/ADC rail.", + recommendation="Use a lower-DCR bead specified for analog, or 0 Ω.", + reference="IC specs", + net=analog, + pins=[ref], + rule_id="PS-FLT-003", + )) + + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.RESISTOR: + continue + pair = _two_nets(comp) + if not pair: + continue + n1, n2 = pair + if _is_power_net(graph, n1) or _is_power_net(graph, n2): + continue + if _is_ground_net(graph, n1) or _is_ground_net(graph, n2): + continue + c1, c2 = _gnd_caps(graph, n1), _gnd_caps(graph, n2) + if bool(c1) == bool(c2): + continue + filt_net, src_net, caps = (n1, n2, c1) if c1 else (n2, n1, c2) + if _is_power_net(graph, filt_net): + continue + r_ohm = _resistor_ohms(comp) + c_f = _sum_known_c(caps) + fc = _fc_rc(r_ohm, c_f) if r_ohm and c_f else None + ics = _ic_refs_on(graph, src_net, filt_net) + _emit( + findings, seen, kind="RC", ref=ref, net=filt_net, fc=fc, + mpn=comp.mpn or "", extra_why="Series R, shunt C to ground (low-pass).", + adc_hz=_adc_rate_hz(graph, ics), + ) + + return findings diff --git a/backend/pinscopex/models.py b/backend/pinscopex/models.py index bcae6b5..329e7a9 100644 --- a/backend/pinscopex/models.py +++ b/backend/pinscopex/models.py @@ -247,9 +247,6 @@ class DesignGraph(BaseModel): # KiCad property table vs uploaded BOM (empty on PADS/EDIF). bom_fields: dict[str, dict] = {} schematic_fields: dict[str, dict] = {} - # KiCad property table vs uploaded BOM (empty on PADS/EDIF). - bom_fields: dict[str, dict] = {} - schematic_fields: dict[str, dict] = {} # -- Traversal helpers -------------------------------------------------- @@ -288,7 +285,8 @@ class DesignGraph(BaseModel): """Capacitor refs connected to a net (useful for decoupling checks).""" return [ r for r in self.components_on_net(net_name) - if self.components[r].component_type == ComponentType.CAPACITOR + if (c := self.components.get(r)) is not None + and c.component_type == ComponentType.CAPACITOR ] def components_by_subtype(self, prefix: str) -> list[str]: diff --git a/backend/pinscopex/thermal_check.py b/backend/pinscopex/thermal_check.py new file mode 100644 index 0000000..339dfa2 --- /dev/null +++ b/backend/pinscopex/thermal_check.py @@ -0,0 +1,294 @@ +"""Schematic thermal estimates for LDOs and dissipating resistors. + +I_load is never inferred from Iout_max. θJA is never invented: missing +theta_ja after a known P is INFO only. Ta defaults to 25 °C. +""" + +from __future__ import annotations + +import re + +from backend.pinscopex.led_current_check import ( + _leg_color, + _net_voltage, + _parse_resistance, + _series_resistor, + _vf, +) +from backend.pinscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, + ResistorSpecs, +) +from backend.pinscopex.passive_rail_check import _pin_name_tokens +from backend.pinscopex.resolve_passives import _parse_spice_value +from backend.pinscopex.validate import _match_constraints + +_TA_C = 25.0 +_TJ_WARN_C = 125.0 +_LOAD_KEYS = ( + "i_load", "i_load_a", "load_current_a", "typical_load_a", + "iout_typical_a", "typical_output_current_a", +) +_IOUT_MAX_KEYS = ( + "iout_max", "iout_max_a", "i_out_max", "max_output_current_a", + "output_current_max_a", +) +_THETA_KEYS = ("theta_ja", "theta_ja_c_per_w", "thermal_resistance_ja", "rth_ja") +_VIN_PIN = re.compile(r"(?:^|[_/])(VIN|IN)(?:$|[_/\d])", re.I) +_VOUT_PIN = re.compile(r"(?:^|[_/])(VOUT|V_OUT|VO|OUT)(?:$|[_/\d])", re.I) +_NOT_OUT = re.compile(r"\b(EN|FB|NC|GND|PG)\b", re.I) + + +def _num(v: object) -> float | None: + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + s = str(v).strip() + try: + return _parse_spice_value(s) + except ValueError: + m = re.match(r"^[-+]?\d*\.?\d+", s) + if m: + try: + return float(m.group(0)) + except ValueError: + return None + return None + + +def _specs_values(comp: Component) -> dict: + specs = comp.specs + values = getattr(specs, "values", None) if specs else None + return values if isinstance(values, dict) else {} + + +def _first(values: dict, keys: tuple[str, ...]) -> float | None: + for k in keys: + if k in values: + n = _num(values[k]) + if n is not None: + return n + return None + + +def _power_rating_w(comp: Component) -> float | None: + specs = comp.specs + if isinstance(specs, ResistorSpecs) and specs.power_rating_w: + raw = specs.power_rating_w + s = str(raw).strip().upper().replace("W", "") + if "/" in s: + try: + a, b = s.split("/", 1) + return float(a) / float(b) + except (TypeError, ValueError): + pass + return _num(raw) or _num(s) + return None + + +def _is_ldo(comp: Component, cons: ComponentConstraints | None) -> bool: + sub = (comp.component_subtype or "") + " " + ((cons.component_subtype if cons else "") or "") + if "ldo" in sub.lower() or "linear_regulator" in sub.lower(): + return True + return False + + +def _pin_net_by_role( + graph: DesignGraph, + comp: Component, + cons: ComponentConstraints | None, + role_re: re.Pattern, +) -> str | None: + for pin_num, net in comp.pins.items(): + tokens = _pin_name_tokens(cons, pin_num) or [pin_num] + if any(role_re.search(t) and not _NOT_OUT.search(t) for t in tokens): + return net + if role_re.search(net or ""): + return net + return None + + +def check_thermal( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints] | None = None, +) -> list[Finding]: + cmap = constraints_map or {} + findings: list[Finding] = [] + findings.extend(_ldo_thermal(graph, cmap)) + findings.extend(_resistor_thermal(graph)) + return findings + + +def _ldo_thermal( + graph: DesignGraph, + cmap: dict[str, ComponentConstraints], +) -> list[Finding]: + out: list[Finding] = [] + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn or comp.value, cmap) + if not _is_ldo(comp, cons): + # VIN+VOUT names still count as a regulator for this check. + vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN) + vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN) + if not (vin_n and vout_n): + continue + else: + vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN) + vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN) + values = _specs_values(comp) + i_load = _first(values, _LOAD_KEYS) + if i_load is None: + # Explicitly ignore Iout_max — that is not a load. + continue + vin = _net_voltage(graph, vin_n) if vin_n else None + vout = _net_voltage(graph, vout_n) if vout_n else None + if vin is None or vout is None or vin <= vout: + continue + p = i_load * (vin - vout) + theta = _first(values, _THETA_KEYS) + net = vout_n or vin_n + if theta is None: + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="thermal", + source="thermal_check", + status="INFO", + finding=( + f"{ref} dissipation ≈ {p:.3g} W " + f"(I_load={i_load:.3g} A, Vin-Vout={vin - vout:.3g} V); " + f"manca theta_ja." + ), + why="θJA is not in the IC specs; Tj is not estimated.", + recommendation="Add theta_ja (or θJA) from the datasheet package table.", + reference="thermal estimate", + net=net, + pins=[ref], + rule_id="PS-TH-001", + )) + continue + tj = _TA_C + p * theta + status = "WARNING" if tj >= _TJ_WARN_C else "INFO" + rule = "PS-TH-002" if status == "WARNING" else "PS-TH-001" + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="thermal", + source="thermal_check", + status=status, + finding=( + f"{ref} Tj ≈ {tj:.0f} °C at Ta={_TA_C:.0f} °C " + f"(P≈{p:.3g} W, θJA={theta:.3g} °C/W)." + ), + why="P = I_load × (Vin−Vout); Tj = Ta + P·θJA. Iout_max was not used as load.", + recommendation="Lower I_load, drop, or θJA (better copper / package) if Tj is high.", + reference="thermal estimate", + net=net, + pins=[ref], + rule_id=rule, + )) + return out + + +def _resistor_thermal(graph: DesignGraph) -> list[Finding]: + out: list[Finding] = [] + seen: set[str] = set() + + for ref in sorted(graph.components_by_subtype("discrete.led")): + led = graph.components.get(ref) + if not led or not led.specs: + continue + values = getattr(led.specs, "values", None) or {} + for pid, net in led.pins.items(): + res = _series_resistor(graph, net, ref) + if not res: + continue + rref, rval, far = res + if rref in seen: + continue + rcomp = graph.components.get(rref) + rating = _power_rating_w(rcomp) if rcomp else None + if rating is None: + continue + color = _leg_color(pid, led) + vf = _vf(values, color) + vrail = _net_voltage(graph, far) + if vrail is None: + vrail = max( + (v for v in (_net_voltage(graph, n) for n in led.pins.values()) if v is not None), + default=None, + ) + if vrail is None or vf is None or vrail <= vf or rval <= 0: + continue + i = (vrail - vf) / rval + p = i * i * rval + if p <= rating: + continue + seen.add(rref) + out.append(Finding( + designator=rref, + mpn=(rcomp.mpn if rcomp else "") or "", + aspect="thermal", + source="thermal_check", + status="WARNING", + finding=( + f"{rref} dissipates ≈ {p:.3g} W on the LED path, " + f"above its {rating:.3g} W rating." + ), + why="P = I²R with I from (Vrail−Vf)/R. Rating comes from power_rating_w.", + recommendation="Use a higher-wattage resistor or raise R to cut current.", + reference="resistor power rating", + net=net, + pins=[rref], + rule_id="PS-TH-003", + )) + + for ref, comp in sorted(graph.components.items()): + if ref in seen or comp.component_type != ComponentType.RESISTOR: + continue + rating = _power_rating_w(comp) + ohms = None + if isinstance(comp.specs, ResistorSpecs): + ohms = float(comp.specs.value_ohms) + if ohms is None: + ohms = _parse_resistance(comp.value) + if rating is None or ohms is None or ohms <= 0: + continue + nets = list(dict.fromkeys(comp.pins.values())) + if len(nets) != 2: + continue + v1, v2 = _net_voltage(graph, nets[0]), _net_voltage(graph, nets[1]) + if v1 is None or v2 is None: + continue + dv = abs(v1 - v2) + if dv <= 0: + continue + i = dv / ohms + p = i * i * ohms + if p <= rating: + continue + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="thermal", + source="thermal_check", + status="WARNING", + finding=( + f"{ref} shunt dissipates ≈ {p:.3g} W " + f"(ΔV={dv:.3g} V / {ohms:.3g} Ω), above its {rating:.3g} W rating." + ), + why="P = I²R with I = ΔV/R from known net voltages. No guessed current.", + recommendation="Raise the wattage rating or the resistance.", + reference="resistor power rating", + net=nets[0], + pins=[ref], + rule_id="PS-TH-003", + )) + return out diff --git a/backend/services/validation.py b/backend/services/validation.py index 5c44cd1..b5a384e 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -51,6 +51,8 @@ from backend.pinscopex.passive_rail_check import ( ) from backend.pinscopex.bom_match_check import check_bom_schematic_match from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage +from backend.pinscopex.filter_check import check_filters +from backend.pinscopex.thermal_check import check_thermal TRACE_VERSION = 1 @@ -76,6 +78,8 @@ def _run_deterministic_checks( graph.schematic_fields, graph.bom_fields, )), ("hf_coverage_check", lambda: check_hf_decoupling_coverage(graph, constraints_map)), + ("filter_check", lambda: check_filters(graph, constraints_map)), + ("thermal_check", lambda: check_thermal(graph, constraints_map)), ): try: out.extend(fn()) diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index b4c46a8..dbf71c5 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.14.0 — 2026-09-10 — Filtri e termico schema + +Deterministic checks now match RC/LC/π/T filters and estimate LDO/resistor dissipation without inventing missing numbers. + +- [New] Filter topology `PS-FLT-001` (fc INFO) / `PS-FLT-002` vs `adc_sample_rate` only when that spec exists. Ferrite DCR `PS-FLT-003` only with a datasheet limit. +- [New] LDO `P = I_load×(Vin−Vout)` and `Tj = 25 + P·θJA`. Missing θJA is `PS-TH-001` INFO. `Iout_max` is not treated as load. +- [New] Resistor `I²R` vs `power_rating_w` on LED paths and shunts with known ΔV (`PS-TH-003`). + ## 2.13.0 — 2026-09-10 — DC-bias C_eff stima The derating table now shows an effective capacitance under DC bias for C0G/X7R/X5R ceramics. It is labelled *stima* — not a vendor lot curve. diff --git a/tests/test_filter_check.py b/tests/test_filter_check.py new file mode 100644 index 0000000..a6b685f --- /dev/null +++ b/tests/test_filter_check.py @@ -0,0 +1,230 @@ +"""Filter topology matcher — RC/LC/π/T fc, ADC compare only with specs.""" + +from __future__ import annotations + +from backend.pinscopex.filter_check import check_filters +from backend.pinscopex.models import ( + CapacitorSpecs, + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + InductorSpecs, + Net, + NetType, + Pin, + PinConnection, + ResistorSpecs, + SimpleComponentSpecs, +) + + +def _graph(components, nets): + 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=components, nets=net_objs) + + +def _res(ref, ohms, n1, n2): + return Component( + reference=ref, value=str(ohms), footprint="", + component_type=ComponentType.RESISTOR, mpn=ref, + pins={"1": n1, "2": n2}, + specs=ResistorSpecs(value_ohms=ohms, value_formatted=str(ohms)), + ) + + +def _cap(ref, farads, net): + return Component( + reference=ref, value="", footprint="", + component_type=ComponentType.CAPACITOR, mpn=ref, + pins={"1": net, "2": "GND"}, + specs=CapacitorSpecs(value_farads=farads, value_formatted="x"), + ) + + +def _l(ref, henries, n1, n2, ferrite=False, dcr=None): + sub = "passive.ferrite_bead" if ferrite else "passive.inductor" + kwargs = dict(value_formatted="x", component_subtype=sub, dcr_ohms=dcr) + if ferrite: + specs = InductorSpecs(impedance_ohm=100.0, value_henries=henries, **kwargs) + else: + specs = InductorSpecs(value_henries=henries, **kwargs) + return Component( + reference=ref, value="", footprint="", + component_type=ComponentType.INDUCTOR, mpn=ref, + component_subtype=sub, + pins={"1": n1, "2": n2}, specs=specs, + ) + + +def _ic(ref="U1", pins=None, values=None, mpn="UTEST"): + return Component( + reference=ref, value="", footprint="", + component_type=ComponentType.IC, mpn=mpn, + pins=pins or {"1": "AIN", "2": "GND"}, + specs=SimpleComponentSpecs( + specs_type="ic", values=values or {}, + ) if values is not None else None, + ) + + +def test_rc_reports_fc_info_without_adc_rate(): + # 1k * 100nF -> fc ≈ 1.59 kHz; no sample rate → INFO not WARNING. + g = _graph( + { + "U1": _ic(), + "R1": _res("R1", 1e3, "AIN", "FILT"), + "C1": _cap("C1", 100e-9, "FILT"), + }, + { + "AIN": (NetType.SIGNAL, [("U1", "1"), ("R1", "1")]), + "FILT": (NetType.SIGNAL, [("R1", "2"), ("C1", "1")]), + "GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]), + }, + ) + findings = check_filters(g) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-FLT-001" + assert findings[0].status == "INFO" + assert findings[0].source == "filter_check" + + +def test_rc_vs_adc_rate_is_warning(): + g = _graph( + { + "U1": _ic(values={"adc_sample_rate": 1e6}), + "R1": _res("R1", 1e3, "AIN", "FILT"), + "C1": _cap("C1", 100e-9, "FILT"), + }, + { + "AIN": (NetType.SIGNAL, [("U1", "1"), ("R1", "1")]), + "FILT": (NetType.SIGNAL, [("R1", "2"), ("C1", "1")]), + "GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]), + }, + ) + findings = check_filters(g) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-FLT-002" + assert findings[0].status == "WARNING" + + +def test_pullup_plus_decoupling_is_not_a_filter(): + g = _graph( + { + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="UTEST", + pins={"1": "SDA", "2": "3V3", "3": "GND"}, + ), + "R1": _res("R1", 4700, "SDA", "3V3"), + "C1": _cap("C1", 100e-9, "3V3"), + }, + { + "SDA": (NetType.SIGNAL, [("U1", "1"), ("R1", "1")]), + "3V3": (NetType.POWER, [("U1", "2"), ("R1", "2"), ("C1", "1")]), + "GND": (NetType.GROUND, [("U1", "3"), ("C1", "2")]), + }, + ) + assert check_filters(g) == [] + + +def test_missing_c_value_does_not_invent_fc_warning(): + c = Component( + reference="C1", value="", footprint="", + component_type=ComponentType.CAPACITOR, mpn="C1", + pins={"1": "FILT", "2": "GND"}, + ) + g = _graph( + { + "U1": _ic(values={"adc_sample_rate": 1e6}), + "R1": _res("R1", 1e3, "AIN", "FILT"), + "C1": c, + }, + { + "AIN": (NetType.SIGNAL, [("U1", "1"), ("R1", "1")]), + "FILT": (NetType.SIGNAL, [("R1", "2"), ("C1", "1")]), + "GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]), + }, + ) + findings = check_filters(g) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-FLT-001" + assert findings[0].status == "INFO" + + +def test_pi_and_t_need_l_and_c(): + g_pi = _graph( + { + "L1": _l("L1", 10e-6, "A", "B"), + "C1": _cap("C1", 100e-9, "A"), + "C2": _cap("C2", 100e-9, "B"), + }, + { + "A": (NetType.SIGNAL, [("L1", "1"), ("C1", "1")]), + "B": (NetType.SIGNAL, [("L1", "2"), ("C2", "1")]), + "GND": (NetType.GROUND, [("C1", "2"), ("C2", "2")]), + }, + ) + pi = check_filters(g_pi) + assert len(pi) == 1 and pi[0].rule_id == "PS-FLT-001" and "π" in pi[0].finding + + g_t = _graph( + { + "L1": _l("L1", 10e-6, "A", "MID"), + "L2": _l("L2", 10e-6, "MID", "B"), + "C1": _cap("C1", 100e-9, "MID"), + }, + { + "A": (NetType.SIGNAL, [("L1", "1")]), + "MID": (NetType.SIGNAL, [("L1", "2"), ("L2", "1"), ("C1", "1")]), + "B": (NetType.SIGNAL, [("L2", "2")]), + "GND": (NetType.GROUND, [("C1", "2")]), + }, + ) + t = check_filters(g_t) + assert len(t) == 1 and "T" in t[0].finding + + +def test_ferrite_dcr_warns_only_with_datasheet_limit(): + cons = { + "UTEST": ComponentConstraints( + mpn="UTEST", + pintable=[Pin(number=1, name="VDDA"), Pin(number=2, name="GND")], + absolute_maximum_ratings=[], rules=[], + ) + } + fb = _l("FB1", None, "VDDA", "3V3", ferrite=True, dcr=2.0) + g = _graph( + { + "U1": _ic(pins={"1": "VDDA", "2": "GND"}, values={}), + "FB1": fb, + "C1": _cap("C1", 100e-9, "VDDA"), + }, + { + "VDDA": (NetType.POWER, [("U1", "1"), ("FB1", "1"), ("C1", "1")]), + "3V3": (NetType.POWER, [("FB1", "2")]), + "GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]), + }, + ) + assert not any(f.rule_id == "PS-FLT-003" for f in check_filters(g, cons)) + + g2 = _graph( + { + "U1": _ic(pins={"1": "VDDA", "2": "GND"}, values={"max_ferrite_dcr_ohms": 0.5}), + "FB1": fb, + "C1": _cap("C1", 100e-9, "VDDA"), + }, + { + "VDDA": (NetType.POWER, [("U1", "1"), ("FB1", "1"), ("C1", "1")]), + "3V3": (NetType.POWER, [("FB1", "2")]), + "GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]), + }, + ) + dcr = [f for f in check_filters(g2, cons) if f.rule_id == "PS-FLT-003"] + assert len(dcr) == 1 and dcr[0].status == "WARNING" diff --git a/tests/test_thermal_check.py b/tests/test_thermal_check.py new file mode 100644 index 0000000..29983c1 --- /dev/null +++ b/tests/test_thermal_check.py @@ -0,0 +1,144 @@ +"""LDO/resistor thermal — no invented I_load or θJA.""" + +from __future__ import annotations + +from backend.pinscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + Net, + NetType, + Pin, + PinConnection, + ResistorSpecs, + SimpleComponentSpecs, +) +from backend.pinscopex.thermal_check import check_thermal + + +def _graph(components, nets): + net_objs = {} + for name, (ntype, volt, conns) in nets.items(): + net_objs[name] = Net( + name=name, net_type=ntype, voltage=volt, + pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns], + ) + return DesignGraph(components=components, nets=net_objs) + + +def _ldo(values, subtype="ic.power.ldo"): + return Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, component_subtype=subtype, + mpn="LDOX", + pins={"1": "VIN", "2": "VOUT", "3": "GND"}, + specs=SimpleComponentSpecs(specs_type="ic", component_subtype=subtype, values=values), + ) + + +def _cons(): + return { + "LDOX": ComponentConstraints( + mpn="LDOX", + component_subtype="ic.power.ldo", + pintable=[ + Pin(number=1, name="VIN"), + Pin(number=2, name="VOUT"), + Pin(number=3, name="GND"), + ], + absolute_maximum_ratings=[], rules=[], + ) + } + + +def test_ldo_without_theta_ja_is_info(): + g = _graph( + {"U1": _ldo({"i_load_a": 0.2})}, + { + "VIN": (NetType.POWER, 5.0, [("U1", "1")]), + "VOUT": (NetType.POWER, 3.3, [("U1", "2")]), + "GND": (NetType.GROUND, 0.0, [("U1", "3")]), + }, + ) + findings = check_thermal(g, _cons()) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-TH-001" + assert findings[0].status == "INFO" + assert "theta_ja" in findings[0].finding.lower() or "theta_ja" in findings[0].why.lower() + + +def test_iout_max_is_not_used_as_load(): + g = _graph( + {"U1": _ldo({"iout_max_a": 0.5, "theta_ja": 160})}, + { + "VIN": (NetType.POWER, 5.0, [("U1", "1")]), + "VOUT": (NetType.POWER, 3.3, [("U1", "2")]), + "GND": (NetType.GROUND, 0.0, [("U1", "3")]), + }, + ) + assert check_thermal(g, _cons()) == [] + + +def test_ldo_hot_tj_is_warning(): + # 0.5 A * 1.7 V = 0.85 W * 160 °C/W + 25 = 161 °C + g = _graph( + {"U1": _ldo({"i_load_a": 0.5, "theta_ja": 160})}, + { + "VIN": (NetType.POWER, 5.0, [("U1", "1")]), + "VOUT": (NetType.POWER, 3.3, [("U1", "2")]), + "GND": (NetType.GROUND, 0.0, [("U1", "3")]), + }, + ) + findings = check_thermal(g, _cons()) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-TH-002" + assert findings[0].status == "WARNING" + + +def test_shunt_over_rating_is_warning(): + r = Component( + reference="R1", value="1", footprint="", + component_type=ComponentType.RESISTOR, mpn="R1", + pins={"1": "A", "2": "B"}, + specs=ResistorSpecs(value_ohms=1.0, value_formatted="1", power_rating_w="0.125"), + ) + g = _graph( + {"R1": r}, + { + "A": (NetType.POWER, 3.3, [("R1", "1")]), + "B": (NetType.POWER, 0.0, [("R1", "2")]), + }, + ) + findings = check_thermal(g) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-TH-003" + assert findings[0].status == "WARNING" + + +def test_led_resistor_within_rating_is_silent(): + led = Component( + reference="D1", value="LED", footprint="", + component_type=ComponentType.DISCRETE, component_subtype="discrete.led", + mpn="LEDX", + pins={"A": "+5V", "K": "NetK"}, + specs=SimpleComponentSpecs( + specs_type="discrete", component_subtype="discrete.led", + values={"forward_voltage_v": 2.0, "forward_current_a": "20mA"}, + ), + ) + r = Component( + reference="R1", value="330", footprint="", + component_type=ComponentType.RESISTOR, mpn="R1", + pins={"1": "NetK", "2": "GND"}, + specs=ResistorSpecs(value_ohms=330.0, value_formatted="330", power_rating_w="0.125"), + ) + g = _graph( + {"D1": led, "R1": r}, + { + "+5V": (NetType.POWER, 5.0, [("D1", "A")]), + "NetK": (NetType.SIGNAL, None, [("D1", "K"), ("R1", "1")]), + "GND": (NetType.GROUND, 0.0, [("R1", "2")]), + }, + ) + assert check_thermal(g) == []