Add schematic filter topology and LDO/resistor thermal checks.
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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.bom_match_check import check_bom_schematic_match
|
||||||
from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage
|
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):
|
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_reset_pullups(graph, cmap))
|
||||||
out.extend(check_bom_schematic_match(graph.schematic_fields, graph.bom_fields))
|
out.extend(check_bom_schematic_match(graph.schematic_fields, graph.bom_fields))
|
||||||
out.extend(check_hf_decoupling_coverage(graph, cmap))
|
out.extend(check_hf_decoupling_coverage(graph, cmap))
|
||||||
|
out.extend(check_filters(graph, cmap))
|
||||||
|
out.extend(check_thermal(graph, cmap))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -247,9 +247,6 @@ class DesignGraph(BaseModel):
|
|||||||
# KiCad property table vs uploaded BOM (empty on PADS/EDIF).
|
# KiCad property table vs uploaded BOM (empty on PADS/EDIF).
|
||||||
bom_fields: dict[str, dict] = {}
|
bom_fields: dict[str, dict] = {}
|
||||||
schematic_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 --------------------------------------------------
|
# -- Traversal helpers --------------------------------------------------
|
||||||
|
|
||||||
@@ -288,7 +285,8 @@ class DesignGraph(BaseModel):
|
|||||||
"""Capacitor refs connected to a net (useful for decoupling checks)."""
|
"""Capacitor refs connected to a net (useful for decoupling checks)."""
|
||||||
return [
|
return [
|
||||||
r for r in self.components_on_net(net_name)
|
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]:
|
def components_by_subtype(self, prefix: str) -> list[str]:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.bom_match_check import check_bom_schematic_match
|
||||||
from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage
|
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
|
TRACE_VERSION = 1
|
||||||
|
|
||||||
@@ -76,6 +78,8 @@ def _run_deterministic_checks(
|
|||||||
graph.schematic_fields, graph.bom_fields,
|
graph.schematic_fields, graph.bom_fields,
|
||||||
)),
|
)),
|
||||||
("hf_coverage_check", lambda: check_hf_decoupling_coverage(graph, constraints_map)),
|
("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:
|
try:
|
||||||
out.extend(fn())
|
out.extend(fn())
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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) == []
|
||||||
Reference in New Issue
Block a user