Files
periscope/backend/periscopex/crystal_cl_check.py
T
micheleandCursor 8d2b85600f Rebrand Pinscope to Periscope across product and codebase.
Rename the core package to periscopex, update UI/docs/Docker/deploy defaults to periscope.michelebigi.it, and keep legacy version/storage key aliases so existing projects keep working.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 20:02:04 +02:00

164 lines
6.1 KiB
Python

"""Crystal load capacitance vs load caps — numbers only when present.
CL_eff ≈ (C1·C2)/(C1+C2) + Cstray. Cstray used only if specs list it;
never invent a stray default. Without CL in specs → skip.
"""
from __future__ import annotations
from backend.periscopex.functional_groups import (
_cap_farads,
_is_ground_net,
load_capacitance_farads,
)
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Finding,
SimpleComponentSpecs,
)
_STRAY_KEYS = ("stray_capacitance_f", "board_stray_f", "cstray_f")
def check_crystal_cl(graph: DesignGraph) -> list[Finding]:
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.CRYSTAL:
continue
cl = load_capacitance_farads(comp)
if cl is None:
continue
load_caps = _load_caps_for_crystal(graph, comp)
if len(load_caps) < 2:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="clock",
source="crystal_cl_check",
status="WARNING",
finding=(
f"{ref} specifies CL={_fmt_f(cl)} but fewer than two load "
f"capacitors were found on its non-ground nets "
f"({[c.reference for c in load_caps] or 'none'})."
),
why="Crystal load capacitance needs a matched C1/C2 pair.",
recommendation="Add or value the two load capacitors on XIN/XOUT.",
reference="netlist topology",
rule_id="PE-XTAL-001",
pins=[ref],
))
continue
# Use the two caps with known farads closest to equal (typical C1≈C2).
valued = [(c, _cap_farads(c)) for c in load_caps]
known = [(c, f) for c, f in valued if f is not None]
if len(known) < 2:
continue
known.sort(key=lambda x: x[1])
# Prefer a pair with similar values: take the two largest known if many.
c1, f1 = known[-2]
c2, f2 = known[-1]
series = (f1 * f2) / (f1 + f2) if (f1 + f2) > 0 else None
if series is None:
continue
stray = _stray_farads(comp)
c_eff = series + (stray or 0.0)
if stray is None:
# Without stray: only flag when series alone already exceeds CL.
if series > cl * 1.25:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="clock",
source="crystal_cl_check",
status="WARNING",
finding=(
f"{ref} CL={_fmt_f(cl)}; C1={c1.reference} {_fmt_f(f1)} and "
f"C2={c2.reference} {_fmt_f(f2)} give series≈{_fmt_f(series)} "
f"(already above CL; board stray not in specs)."
),
why="Series combination of load caps exceeds specified CL without needing stray.",
recommendation="Reduce load caps or confirm the datasheet CL value.",
reference="netlist topology",
rule_id="PE-XTAL-002",
pins=[ref, c1.reference, c2.reference],
))
elif series < cl * 0.5:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="clock",
source="crystal_cl_check",
status="INFO",
finding=(
f"{ref} CL={_fmt_f(cl)}; series of {c1.reference}/{c2.reference} "
f"≈{_fmt_f(series)} (stray unknown — verify against datasheet)."
),
why="Without stray capacitance in specs, effective CL cannot be fully checked.",
recommendation="Confirm Cstray or populate load_capacitance / stray in crystal specs.",
reference="netlist topology",
rule_id="PE-XTAL-003",
pins=[ref, c1.reference, c2.reference],
))
continue
if c_eff > cl * 1.25 or c_eff < cl * 0.75:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="clock",
source="crystal_cl_check",
status="WARNING",
finding=(
f"{ref} CL={_fmt_f(cl)}; C_eff≈{_fmt_f(c_eff)} "
f"(series {_fmt_f(series)} + stray {_fmt_f(stray)}) "
f"from {c1.reference}/{c2.reference}."
),
why="Effective load capacitance should stay near the crystal's specified CL.",
recommendation="Adjust C1/C2 so C_eff ≈ CL.",
reference="netlist topology",
rule_id="PE-XTAL-002",
pins=[ref, c1.reference, c2.reference],
))
return findings
def _load_caps_for_crystal(graph: DesignGraph, crystal: Component) -> list[Component]:
caps: dict[str, Component] = {}
for net in crystal.pins.values():
if not net or _is_ground_net(graph, net):
continue
for cref in graph.capacitors_on_net(net):
cap = graph.components.get(cref)
if cap:
caps[cref] = cap
return list(caps.values())
def _stray_farads(comp: Component) -> float | None:
specs = comp.specs
if not isinstance(specs, SimpleComponentSpecs):
return None
for key in _STRAY_KEYS:
raw = specs.values.get(key)
if raw is None:
continue
try:
v = float(raw)
except (TypeError, ValueError):
continue
if v >= 0:
return v
return None
def _fmt_f(farads: float) -> str:
if farads >= 1e-6:
return f"{farads * 1e6:.3g}µF"
if farads >= 1e-9:
return f"{farads * 1e9:.3g}nF"
return f"{farads * 1e12:.3g}pF"