diff --git a/backend/pinscopex/derating.py b/backend/pinscopex/derating.py index e54d3f9..2af4caa 100644 --- a/backend/pinscopex/derating.py +++ b/backend/pinscopex/derating.py @@ -5,11 +5,62 @@ from __future__ import annotations import re from backend.pinscopex.models import ComponentType, DesignGraph, NetType +from backend.pinscopex.resolve_passives import _format_value from backend.pinscopex.utils import natural_sort_key # Dielectric strings that indicate ceramic capacitors _CERAMIC_DIELECTRICS = {"X7R", "X5R", "C0G", "NP0", "Y5V", "X7S", "X6S", "X8R", "C0G (NP0)"} +# Remaining C/C0 vs V/Vrated. Empirical stima, not a vendor lot curve. +_BIAS_CURVES: dict[str, list[tuple[float, float]]] = { + "c0g": [(0.0, 1.0), (1.2, 1.0)], + "x7r": [(0.0, 1.0), (0.25, 0.90), (0.50, 0.70), (0.75, 0.45), (1.0, 0.30), (1.2, 0.22)], + "x5r": [(0.0, 1.0), (0.25, 0.82), (0.50, 0.55), (0.75, 0.32), (1.0, 0.18), (1.2, 0.12)], + "y5v": [(0.0, 1.0), (0.25, 0.50), (0.50, 0.20), (0.80, 0.12), (1.0, 0.10)], +} + + +def _lerp(curve: list[tuple[float, float]], x: float) -> float: + if x <= curve[0][0]: + return curve[0][1] + for (x0, y0), (x1, y1) in zip(curve, curve[1:]): + if x <= x1: + if x1 == x0: + return y1 + t = (x - x0) / (x1 - x0) + return y0 + t * (y1 - y0) + return curve[-1][1] + + +def _bias_family(dielectric: str | None) -> str | None: + if not dielectric: + return None + u = dielectric.upper() + if "C0G" in u or "NP0" in u or "NPO" in u: + return "c0g" + if "Y5V" in u: + return "y5v" + if "X5R" in u or "X6S" in u: + return "x5r" + if "X7R" in u or "X7S" in u or "X8R" in u: + return "x7r" + return None + + +def dc_bias_remaining( + dielectric: str | None, + v_op: float | None, + rated_v: float | None, +) -> float | None: + """Fraction of nominal C remaining under DC bias, or None if not modelled. + + Labelled a *stima*: class-2 MLCC curves vary by lot, thickness and vendor. + """ + family = _bias_family(dielectric) + if family is None or v_op is None or rated_v is None or rated_v <= 0: + return None + return _lerp(_BIAS_CURVES[family], max(0.0, v_op) / rated_v) + def _parse_voltage_rating(s: str | None) -> float | None: """Extract numeric voltage from a rating string like '16V', '25V', '2.5V'.""" @@ -64,10 +115,12 @@ def build_derating_table(graph: DesignGraph) -> list[dict]: rated_v: float | None = None value_fmt: str | None = None dielectric: str | None = None + c_nom: float | None = None if comp.specs and hasattr(comp.specs, "voltage_rating_v"): rated_v = _parse_voltage_rating(comp.specs.voltage_rating_v) value_fmt = getattr(comp.specs, "value_formatted", None) dielectric = getattr(comp.specs, "dielectric", None) + c_nom = getattr(comp.specs, "value_farads", None) # Operating voltage: max non-zero voltage among connected nets op_voltage: float | None = None @@ -107,6 +160,10 @@ def build_derating_table(graph: DesignGraph) -> list[dict]: net_minus = by_v[0][0] net_plus = by_v[-1][0] + factor = dc_bias_remaining(dielectric, op_voltage, rated_v) + c_eff = (c_nom * factor) if (c_nom is not None and factor is not None) else None + c_eff_fmt = _format_value(c_eff, "F") if c_eff is not None else None + rows.append({ "designator": comp.reference, "mpn": comp.mpn, @@ -117,6 +174,12 @@ def build_derating_table(graph: DesignGraph) -> list[dict]: "net_plus": net_plus, "net_minus": net_minus, "dielectric_category": _dielectric_category(comp.component_subtype, dielectric), + "dielectric": dielectric, + "c_nominal_f": c_nom, + "dc_bias_factor": factor, + "c_eff_f": c_eff, + "c_eff_formatted": c_eff_fmt, + "dc_bias_model": "stima" if factor is not None else None, }) rows.sort(key=lambda r: natural_sort_key(r["designator"])) diff --git a/backend/pinscopex/eval_report.py b/backend/pinscopex/eval_report.py index 4cf4c5e..8c47c89 100644 --- a/backend/pinscopex/eval_report.py +++ b/backend/pinscopex/eval_report.py @@ -21,6 +21,7 @@ from backend.pinscopex.passive_rail_check import ( check_supply_decoupling, ) from backend.pinscopex.bom_match_check import check_bom_schematic_match +from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage class EvalScores(BaseModel): @@ -81,6 +82,7 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]: out.extend(check_i2c_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_hf_decoupling_coverage(graph, cmap)) return out diff --git a/backend/pinscopex/hf_coverage_check.py b/backend/pinscopex/hf_coverage_check.py new file mode 100644 index 0000000..380b5cb --- /dev/null +++ b/backend/pinscopex/hf_coverage_check.py @@ -0,0 +1,109 @@ +"""HF decoupling coverage — bulk without a small ceramic. + +Without a switching frequency this does not invent a Z(f) target. +INFO only: HF coverage depends on a ~100 nF close to the pin. +""" + +from __future__ import annotations + +from backend.pinscopex.models import ComponentType, DesignGraph, Finding, NetType +from backend.pinscopex.passive_rail_check import ( + _cap_farads, + _is_ground_net, + _is_ic_supply_pin, + _is_nc_net, + _is_regulator_output_pin, + _pin_label, +) +from backend.pinscopex.validate import _match_constraints + +_BULK_MIN_F = 1e-6 +_HF_MAX_F = 1e-6 +_HF_MIN_F = 1e-9 + + +def _esl_hint(footprint: str) -> str: + fp = (footprint or "").upper() + if "0402" in fp: + return "typical ESL ~0.4 nH (0402 stima)" + if "0603" in fp: + return "typical ESL ~0.6 nH (0603 stima)" + if "0805" in fp: + return "typical ESL ~0.8 nH (0805 stima)" + return "ESL depends on package (stima)" + + +def _valued_gnd_caps(graph: DesignGraph, net_name: str) -> list[tuple[str, float]]: + out: list[tuple[str, float]] = [] + unknown = False + for ref in graph.capacitors_on_net(net_name): + cap = graph.components[ref] + others = {n for n in cap.pins.values() if n != net_name} + if not any(_is_ground_net(graph, n) for n in others): + continue + farads = _cap_farads(cap) + if farads is None: + unknown = True + continue + out.append((ref, farads)) + if unknown: + return [] + return out + + +def check_hf_decoupling_coverage( + graph: DesignGraph, + constraints_map: dict, +) -> list[Finding]: + findings: list[Finding] = [] + seen: set[str] = set() + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn or comp.value, constraints_map) + for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])): + if net_name in seen or _is_nc_net(net_name): + continue + is_rail = _is_ic_supply_pin(graph, cons, pin_num, net_name) or ( + _is_regulator_output_pin(cons, pin_num) + ) + if not is_rail: + continue + net = graph.nets.get(net_name) + if net and net.net_type == NetType.GROUND: + continue + seen.add(net_name) + caps = _valued_gnd_caps(graph, net_name) + if not caps: + continue + has_bulk = any(c >= _BULK_MIN_F for _, c in caps) + has_hf = any(_HF_MIN_F <= c < _HF_MAX_F for _, c in caps) + if not (has_bulk and not has_hf): + continue + bulk_ref = next(r for r, c in caps if c >= _BULK_MIN_F) + fp = graph.components[bulk_ref].footprint + pin_label = _pin_label(cons, pin_num, net_name) + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="decoupling", + source="hf_coverage_check", + status="INFO", + finding=( + f"{ref} net '{net_name}' ({pin_label}) has bulk capacitance " + f"but no ~100 nF ceramic for HF." + ), + why=( + f"Parallel Z(f) of large C is inductive above a few hundred " + f"kHz ({_esl_hint(fp)}). Without f_sw this is not an Ω target." + ), + recommendation=( + f"Add a 10–100 nF ceramic from '{net_name}' to ground near " + f"{ref}, in parallel with the bulk cap." + ), + reference="netlist topology (stima)", + net=net_name, + pins=[f"{ref}.{pin_num}"], + rule_id="PS-ESR-001", + )) + return findings diff --git a/backend/services/validation.py b/backend/services/validation.py index 694031a..5c44cd1 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -50,6 +50,7 @@ from backend.pinscopex.passive_rail_check import ( check_supply_decoupling, ) from backend.pinscopex.bom_match_check import check_bom_schematic_match +from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage TRACE_VERSION = 1 @@ -74,6 +75,7 @@ def _run_deterministic_checks( ("bom_match_check", lambda: check_bom_schematic_match( graph.schematic_fields, graph.bom_fields, )), + ("hf_coverage_check", lambda: check_hf_decoupling_coverage(graph, constraints_map)), ): try: out.extend(fn()) diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index f6e0e4d..b4c46a8 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 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. + +- [New] `C_eff` column from an empirical V/Vrated table. Tantalum/electrolytic and unknown dielectrics are left blank. +- [Improved] C0G/NP0 stays at nominal C; X7R at 50% of rated V is about 70% of C. +- [New] Bulk C without a ~100 nF ceramic is `PS-ESR-001` INFO (no invented Z(f) target). + ## 2.12.0 — 2026-09-10 — Pull-up sizing and LDO Cout Deterministic schema checks now size I2C pull-ups, flag NRST pull-downs, and look at LDO VOUT capacitance — still WARNING, never a invented datasheet µF ERROR. diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx index 07029a8..f455d59 100644 --- a/frontend/src/app/(app)/project/[id]/page.tsx +++ b/frontend/src/app/(app)/project/[id]/page.tsx @@ -751,6 +751,9 @@ function DeratingTable({ {failCount} fail )} +

+ C_eff is an empirical DC-bias stima (C0G/X7R/X5R), not a Murata lot curve. +

@@ -760,6 +763,7 @@ function DeratingTable({ Designator MPN Value + C_eff Type Net+ Net− @@ -800,6 +804,18 @@ function DeratingTable({ {row.value_formatted ?? } + + {row.c_eff_formatted ? ( + + {row.c_eff_formatted} + {row.dc_bias_model === "stima" && ( + stima + )} + + ) : ( + + )} + {row.dielectric_category ? ( diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0a354ac..1806591 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -305,6 +305,12 @@ export interface DeratingRow { net_plus: string | null; net_minus: string | null; dielectric_category: "ceramic" | "tantalum" | "electrolytic" | null; + dielectric?: string | null; + c_nominal_f?: number | null; + dc_bias_factor?: number | null; + c_eff_f?: number | null; + c_eff_formatted?: string | null; + dc_bias_model?: "stima" | null; } export interface DeratingSettings { diff --git a/simple_project/eval_golden.json b/simple_project/eval_golden.json index de85944..001bb37 100644 --- a/simple_project/eval_golden.json +++ b/simple_project/eval_golden.json @@ -4,6 +4,7 @@ "min_nets": 30, "deterministic_keys": [ "PS-I2C-001|U3|/I2C0.SDA", - "PS-I2C-001|U3|/I2C0.SCL" + "PS-I2C-001|U3|/I2C0.SCL", + "PS-ESR-001|U1|+3V3" ] } diff --git a/tests/test_derating.py b/tests/test_derating.py new file mode 100644 index 0000000..4e9d2ef --- /dev/null +++ b/tests/test_derating.py @@ -0,0 +1,109 @@ +"""DC-bias C_eff stima — not a Murata lot curve. + +Favor: C0G stays at C; X7R at 50% Vr loses ~30%; C_eff formatted. +Against: tantalum uses C_nom (no MLCC model); missing Vr or C skips C_eff; +C0G is not treated as X7R. +""" + +from __future__ import annotations + +from backend.pinscopex.derating import ( + build_derating_table, + dc_bias_remaining, +) +from backend.pinscopex.models import ( + CapacitorSpecs, + Component, + ComponentType, + DesignGraph, + Net, + NetType, + PinConnection, +) + + +def test_c0g_keeps_full_capacitance(): + assert dc_bias_remaining("C0G", v_op=16.0, rated_v=16.0) == 1.0 + assert dc_bias_remaining("NP0", v_op=10.0, rated_v=16.0) == 1.0 + + +def test_x7r_at_half_rated_is_about_70_percent(): + f = dc_bias_remaining("X7R", v_op=8.0, rated_v=16.0) + assert f is not None + assert 0.65 <= f <= 0.75 + + +def test_x7r_at_zero_bias_is_nominal(): + assert dc_bias_remaining("X7R", v_op=0.0, rated_v=16.0) == 1.0 + + +def test_tantalum_has_no_mlcc_bias_model(): + assert dc_bias_remaining("tantalum", v_op=8.0, rated_v=16.0) is None + + +def test_missing_voltage_or_value_skips_c_eff(): + assert dc_bias_remaining("X7R", v_op=None, rated_v=16.0) is None + assert dc_bias_remaining("X7R", v_op=8.0, rated_v=None) is None + + +def _cap(ref, dielectric, farads, rated, net="3V3"): + return Component( + reference=ref, value="", footprint="", + component_type=ComponentType.CAPACITOR, + component_subtype="passive.capacitor.ceramic", + mpn=ref, + pins={"1": net, "2": "GND"}, + specs=CapacitorSpecs( + value_farads=farads, + value_formatted="10uF", + voltage_rating_v=f"{rated}V", + dielectric=dielectric, + ), + ) + + +def test_derating_row_includes_c_eff_stima(): + c1 = _cap("C1", "X7R", 10e-6, 16) + g = DesignGraph( + components={ + "C1": c1, + }, + nets={ + "3V3": Net( + name="3V3", net_type=NetType.POWER, voltage=8.0, + pins=[PinConnection(component_ref="C1", pin_number="1")], + ), + "GND": Net( + name="GND", net_type=NetType.GROUND, voltage=0.0, + pins=[PinConnection(component_ref="C1", pin_number="2")], + ), + }, + ) + rows = build_derating_table(g) + assert len(rows) == 1 + row = rows[0] + assert row["dc_bias_model"] == "stima" + assert row["c_nominal_f"] == 10e-6 + assert row["c_eff_f"] is not None + assert 6.5e-6 <= row["c_eff_f"] <= 7.5e-6 + assert "uF" in (row["c_eff_formatted"] or "") + + +def test_c0g_row_c_eff_equals_nominal(): + c1 = _cap("C9", "C0G", 18e-12, 50) + g = DesignGraph( + components={"C9": c1}, + nets={ + "3V3": Net( + name="3V3", net_type=NetType.POWER, voltage=3.3, + pins=[PinConnection(component_ref="C9", pin_number="1")], + ), + "GND": Net( + name="GND", net_type=NetType.GROUND, + pins=[PinConnection(component_ref="C9", pin_number="2")], + ), + }, + ) + row = build_derating_table(g)[0] + assert row["c_eff_f"] == 18e-12 + assert row["dc_bias_factor"] == 1.0 diff --git a/tests/test_eval_report.py b/tests/test_eval_report.py index dfbbf50..deac324 100644 --- a/tests/test_eval_report.py +++ b/tests/test_eval_report.py @@ -85,8 +85,9 @@ def test_simple_project_eval_matches_committed_golden(): assert scores.graph_ok, scores.graph_errors assert scores.precision == 1.0 assert scores.recall == 1.0 - assert scores.finding_count == 2 + assert scores.finding_count == 3 assert scores.by_status["WARNING"] == 2 + assert scores.by_status["INFO"] == 1 def test_simple_project_eval_rejects_truncated_graph(tmp_path: Path): diff --git a/tests/test_hf_coverage_check.py b/tests/test_hf_coverage_check.py new file mode 100644 index 0000000..a617421 --- /dev/null +++ b/tests/test_hf_coverage_check.py @@ -0,0 +1,93 @@ +"""HF coverage INFO when bulk C exists without a 100 nF-class ceramic.""" + +from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage +from backend.pinscopex.models import ( + CapacitorSpecs, + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + Net, + NetType, + Pin, + PinConnection, +) + + +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 _ic(): + return Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="UTEST", + pins={"1": "3V3", "2": "GND"}, + ) + + +def _cmap(): + return { + "UTEST": ComponentConstraints( + mpn="UTEST", + pintable=[Pin(number=1, name="VDD"), Pin(number=2, name="GND")], + absolute_maximum_ratings=[], rules=[], + ) + } + + +def _cap(ref, farads, net="3V3"): + return Component( + reference=ref, value="", footprint="C_0603", + component_type=ComponentType.CAPACITOR, mpn=ref, + pins={"1": net, "2": "GND"}, + specs=CapacitorSpecs(value_farads=farads, value_formatted="x"), + ) + + +def test_bulk_only_is_info_ps_esr_001(): + g = _graph( + {"U1": _ic(), "C1": _cap("C1", 10e-6)}, + { + "3V3": (NetType.POWER, [("U1", "1"), ("C1", "1")]), + "GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]), + }, + ) + findings = check_hf_decoupling_coverage(g, _cmap()) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-ESR-001" + assert findings[0].status == "INFO" + + +def test_bulk_plus_100n_is_silent(): + g = _graph( + {"U1": _ic(), "C1": _cap("C1", 10e-6), "C2": _cap("C2", 100e-9)}, + { + "3V3": (NetType.POWER, [("U1", "1"), ("C1", "1"), ("C2", "1")]), + "GND": (NetType.GROUND, [("U1", "2"), ("C1", "2"), ("C2", "2")]), + }, + ) + assert check_hf_decoupling_coverage(g, _cmap()) == [] + + +def test_unknown_cap_value_is_not_guessed(): + c = Component( + reference="C1", value="", footprint="", + component_type=ComponentType.CAPACITOR, mpn="C1", + pins={"1": "3V3", "2": "GND"}, + ) + g = _graph( + {"U1": _ic(), "C1": c}, + { + "3V3": (NetType.POWER, [("U1", "1"), ("C1", "1")]), + "GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]), + }, + ) + assert check_hf_decoupling_coverage(g, _cmap()) == []