Files
periscope/periscope/src/backend/periscopex/bom_pcb_check.py
T
michele 8f434ffd31 Skip module-die and truncated QFN pintable false PE-BOM hits (2.63.3).
WROOM lands are not the ESP32 die table (U11). QFN-48 pads 25–48 are
not extras when extraction stopped at 1–24 (U12). VQFN is the QFN
family (U19 24-QFN 4×4). Real extra signal pads still ERROR.
2026-09-22 00:57:57 +02:00

755 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings.
Skip when a side is missing. No invented JEDEC land patterns or IEC
clearances. PE-BOM-011 joins pintable × lib × PCB by pin name — never
by scalar pin_count. Pad ≠ via ≠ track ≠ zone. USB-C A5/B5 are pins,
not EP.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from backend.periscopex.models import (
AbsMaxRating,
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
InductorSpecs,
LayoutGraph,
PackageInfo,
)
from backend.periscopex.thermal_check import (
_LOAD_KEYS,
_first,
_net_voltage,
_specs_values,
)
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.parsers_kicad_pcb import parse_kicad_mod_pads
_FAMILIES = (
"LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN",
"WSON", "USON", "SOIC", "SSOP", "TSSOP", "MSOP", "SOP",
"SOT-223", "SOT223", "SOT-23", "SOT23", "SOT-89", "SOT89",
"WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70",
"TO-252", "TO252", "TO-263", "QFP",
)
# Same land family: VQFN/WQFN/HVQFN are QFN (24-QFN 4×4 ≠ a different package).
_FAMILY_CANON = {
"VQFN": "QFN",
"WQFN": "QFN",
"HVQFN": "QFN",
}
_QFN_PIN_N = re.compile(r"(?:HVQFN|VQFN|WQFN|QFN)[_-](\d+)\b", re.I)
_MODULE_FP = re.compile(
r"WROOM|WROVER|ESP-WROOM|RF[_-]?MODULE|CASTELLAT",
re.I,
)
_EP_PAD = re.compile(
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
re.I,
)
_EP_SPLIT = re.compile(r"^\d+_\d+$")
_SH_PAD = re.compile(r"^(?:SH|SHIELD)(?:[_-].*)?$", re.I)
_V_PARAM = re.compile(
r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)",
re.I,
)
_NOT_V = re.compile(r"(IOUT|CURRENT|POWER|PD|TJ|TSTG|TEMP)", re.I)
_GND_DIFF_V = re.compile(
r"ground voltage|vss to vss|vss.?to.?vss|thermal pad",
re.I,
)
_RAIL_TOKEN = re.compile(r"\b(V[A-Z][A-Z0-9]*)\b")
_T_PARAM = re.compile(r"(T[JA]|TJMAX|T_J|TSTG|TSTORAGE|OPERATING.?TEMP)", re.I)
_I_PARAM = re.compile(r"(IOUT|I_OUT|IDC|ICONT|CURRENT)", re.I)
def _family(blob: str) -> str | None:
u = (blob or "").upper().replace(" ", "")
if not u:
return None
for fam in _FAMILIES:
if fam.replace("-", "") in u.replace("-", ""):
return _FAMILY_CANON.get(fam, fam)
return None
def _bom_fp(graph: DesignGraph, ref: str) -> str:
row = (graph.bom_fields or {}).get(ref) or {}
return str(row.get("footprint") or row.get("Footprint") or "")
def _sch_fp(graph: DesignGraph, ref: str, comp: Component) -> str:
row = (graph.schematic_fields or {}).get(ref) or {}
return str(row.get("footprint") or comp.footprint or "")
def _norm_pin(s: str) -> str:
return str(s).strip().upper()
def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None) -> bool:
"""Named EP / thermal / split / numeric pin_count+1. Not USB-C A5/B5."""
s = str(number).strip()
pf = str(pinfunction or "").strip()
if _SH_PAD.match(s) or (pf and _SH_PAD.match(pf)):
return False
if _EP_PAD.match(s) or (pf and _EP_PAD.match(pf)):
return True
if s.lower().replace(" ", "") in {"thermalpad", "thermal"}:
return True
if _EP_SPLIT.match(s):
return True
if pin_count and s.isdigit() and int(s) == pin_count + 1:
return True
return False
def _is_shield_land(number: str, pinfunction: str = "") -> bool:
s = str(number).strip()
pf = str(pinfunction or "").strip()
return bool(_SH_PAD.match(s) or (pf and _SH_PAD.match(pf)))
def _cad_blob(*parts: str) -> str:
return " ".join(p for p in parts if (p or "").strip())
def _is_module_land(blob: str) -> bool:
"""WROOM / RF module footprint — not the silicon die pintable."""
return bool(_MODULE_FP.search(blob or ""))
def _declared_io_pins(pkg: PackageInfo | None, *blobs: str) -> int | None:
"""Largest QFN-n / pin_count seen in CAD or package_info. Not a pad census."""
n: list[int] = []
if pkg and pkg.pin_count:
try:
n.append(int(pkg.pin_count))
except (TypeError, ValueError):
pass
for b in blobs:
for m in _QFN_PIN_N.finditer(b or ""):
n.append(int(m.group(1)))
return max(n) if n else None
def _drop_explainable_extras(
extra: list[str] | set[str],
*,
missing: list[str] | set[str],
is_module: bool,
declared: int | None,
) -> tuple[list[str], str]:
"""Module lands beyond the die, or QFN-n pads the pintable never listed.
Missing datasheet pins are never dropped. Pad ≠ via.
"""
leftover: list[str] = []
reasons: list[str] = []
miss = {str(x) for x in missing}
if miss:
return sorted(extra), ""
extras = [str(x) for x in extra]
if not extras:
return [], ""
if is_module:
reasons.append("module-footprint-vs-die-pintable")
return [], reasons[0]
if declared:
kept: list[str] = []
dropped = False
for e in extras:
if e.isdigit() and int(e) <= declared:
dropped = True
continue
kept.append(e)
if dropped and not kept:
reasons.append(f"pintable-truncated-vs-declared-{declared}")
return [], reasons[0]
extras = kept
return sorted(extras), (reasons[0] if reasons else "")
def _ds_join_keys(cons: ComponentConstraints | None) -> set[str]:
"""Pintable pin **numbers** (A5, 17, EP). Names like P1 are not pad ids."""
out: set[str] = set()
if not cons:
return out
for pin in cons.pintable or []:
n = _norm_pin(str(pin.number or ""))
if n:
out.add(n)
return out
def _join_keys_from_ids(
ids: list[tuple[str, str]],
*,
pin_count: int | None,
ds_keys: set[str],
) -> set[str]:
"""Contact pads enter the join. EP/SH only if the pintable names them."""
out: set[str] = set()
for number, pinfunction in ids:
n = str(number).strip()
if not n:
continue
pf = str(pinfunction or "")
key = _norm_pin(n)
if _is_shield_land(n, pf) or _is_ep_land(n, pf, pin_count):
if key in ds_keys:
out.add(key)
continue
out.add(key)
return out
def _pads_as_ids(fp_layout) -> list[tuple[str, str]]:
if fp_layout is None:
return []
return [
(str(p.number), str(getattr(p, "pinfunction", "") or ""))
for p in fp_layout.pads
]
def _kicad_footprint_env_dirs() -> list[Path]:
out: list[Path] = []
for key in (
"KICAD10_FOOTPRINT_DIR",
"KICAD9_FOOTPRINT_DIR",
"KICAD8_FOOTPRINT_DIR",
"KICAD_FOOTPRINT_DIR",
):
raw = os.environ.get(key) or ""
for part in raw.split(os.pathsep):
p = Path(part.strip())
if part.strip() and p.is_dir():
out.append(p)
return out
def resolve_kicad_mod(
footprint: str,
search_dirs: list[Path] | None = None,
) -> Path | None:
"""Locate ``Lib:Name`` as ``Lib.pretty/Name.kicad_mod``. None if missing."""
name = (footprint or "").strip()
if not name:
return None
rels: list[str] = []
if ":" in name:
lib, mod = name.split(":", 1)
rels.append(str(Path(f"{lib}.pretty") / f"{mod}.kicad_mod"))
rels.append(f"{mod}.kicad_mod")
else:
rels.append(f"{name}.kicad_mod")
rels.append(str(Path(f"{name}.pretty") / f"{name}.kicad_mod"))
dirs = list(search_dirs or []) + _kicad_footprint_env_dirs()
seen: set[str] = set()
for root in dirs:
try:
root_p = Path(root)
except TypeError:
continue
if not root_p.is_dir():
continue
for rel in rels:
cand = root_p / rel
key = str(cand)
if key in seen:
continue
seen.add(key)
if cand.is_file():
return cand
return None
def _lib_join_keys(
footprint: str,
*,
pin_count: int | None,
ds_keys: set[str],
search_dirs: list[Path] | None,
) -> tuple[set[str] | None, str]:
"""Lib pad set, or None when the ``.kicad_mod`` is not on disk."""
path = resolve_kicad_mod(footprint, search_dirs)
if path is None:
return None, ""
ids = parse_kicad_mod_pads(path)
if ids is None:
return None, str(path)
return _join_keys_from_ids(ids, pin_count=pin_count, ds_keys=ds_keys), str(path)
def _package_row(cons: ComponentConstraints | None, specs) -> PackageInfo | None:
if cons and cons.package_info:
return cons.package_info
pkg = getattr(specs, "package_info", None)
if isinstance(pkg, PackageInfo):
return pkg
return None
def _cap_vrated(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, CapacitorSpecs) and specs.voltage_rating_v:
try:
return float(str(specs.voltage_rating_v).replace("V", "").strip())
except (TypeError, ValueError):
return None
return None
def _ind_irated(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, InductorSpecs) and specs.current_rating_a:
try:
return float(str(specs.current_rating_a).replace("A", "").strip())
except (TypeError, ValueError):
return None
return None
def _rating_unit_v(r: AbsMaxRating) -> bool:
return (r.unit or "").lower() in {"v", "volt", "volts"}
def _rating_unit_c(r: AbsMaxRating) -> bool:
u = (r.unit or "").lower().replace("°", "")
return u in {"c", "degc", "celsius"}
def _rating_unit_a(r: AbsMaxRating) -> bool:
return (r.unit or "").lower() in {"a", "amp", "amps"}
def check_bom_pcb_datasheet(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
footprint_dirs: list[Path] | None = None,
) -> list[Finding]:
"""Package family + named pin join. Skip a side when it is missing."""
cmap = constraints_map or {}
out: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if ref.startswith("#"):
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
fp_layout = layout.footprints.get(ref) if layout else None
pcb_name = (fp_layout.footprint if fp_layout else "") or ""
sch_name = _sch_fp(graph, ref, comp)
bom_name = _bom_fp(graph, ref)
pkg = _package_row(cons, comp.specs)
out.extend(_package_findings(
ref, comp, pkg, sch_name, bom_name, pcb_name,
))
out.extend(_pinout_findings(
ref, comp, cons, fp_layout, pkg, footprint_dirs,
))
out.extend(_voltage_findings(graph, ref, comp, cons))
out.extend(_temp_findings(ref, comp, cons))
out.extend(_current_findings(graph, ref, comp, cons))
return out
def _mismatch(
*,
ref: str, mpn: str, rule_id: str, finding: str, facts: str,
requirement: str, rec: str, status: str = "ERROR",
inference: str = "", net: str | None = None,
evidence_status: str = "SUFFICIENT",
) -> Finding:
return Finding(
designator=ref,
mpn=mpn,
aspect="bom_pcb",
finding=finding,
facts=facts,
requirement=requirement,
inference=inference or "Measured three-way mismatch — not a guessed land pattern.",
why=requirement,
status=status,
recommendation=rec,
action=rec,
source="bom_pcb_check",
rule_id=rule_id,
evidence_status=evidence_status,
net=net,
pins=[ref],
)
def _package_findings(
ref: str,
comp: Component,
pkg: PackageInfo | None,
sch_name: str,
bom_name: str,
pcb_name: str,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
names = {
"schematic": sch_name,
"BOM": bom_name,
"PCB": pcb_name,
}
present = {k: v for k, v in names.items() if (v or "").strip()}
fams = {k: _family(v) for k, v in present.items()}
known = {k: f for k, f in fams.items() if f}
if len(set(known.values())) > 1:
rec = (
f"Align {ref} footprint/package across BOM, schematic, and PCB "
f"to the datasheet package {pkg.package if pkg else '(unknown)'}."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-010",
finding=(
f"{ref} package family disagrees: "
+ ", ".join(f"{k}={v}" for k, v in sorted(known.items()))
+ (f"; datasheet={pkg.package}" if pkg else ".")
),
facts=(
f"sch={sch_name!r} bom={bom_name!r} pcb={pcb_name!r} "
f"datasheet={getattr(pkg, 'package', None)!r}."
),
requirement="BOM, PCB footprint, and datasheet package family must match.",
rec=rec,
))
ds_fam = _family(pkg.package) if pkg else None
if ds_fam and known and ds_fam not in known.values() and any(known.values()):
# Datasheet family vs every CAD family.
if all(f != ds_fam for f in known.values()):
rec = f"Change {ref}'s footprint to the datasheet package {pkg.package}."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-010",
finding=(
f"{ref} datasheet package {pkg.package} ({ds_fam}) does not "
f"match CAD families {sorted(set(known.values()))}."
),
facts=f"datasheet={pkg.package!r}; CAD={present!r}.",
requirement="PCB/BOM footprint family must match datasheet package_info.",
rec=rec,
))
return out
def _pin_delta(
*,
ref: str,
mpn: str,
finding: str,
facts: str,
requirement: str,
rec: str,
status: str = "ERROR",
evidence_status: str = "SUFFICIENT",
) -> Finding:
return _mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
finding=finding, facts=facts, requirement=requirement, rec=rec,
status=status, evidence_status=evidence_status,
)
def _pinout_findings(
ref: str,
comp: Component,
cons: ComponentConstraints | None,
fp_layout,
pkg: PackageInfo | None,
footprint_dirs: list[Path] | None = None,
) -> list[Finding]:
"""Join pintable × lib × PCB by name. Never ERROR on pin_count alone."""
ds = _ds_join_keys(cons)
if not ds:
return []
mpn = comp.mpn or ""
pin_count = pkg.pin_count if pkg else None
pcb_ids = _pads_as_ids(fp_layout)
fp_name = ""
if fp_layout is not None:
fp_name = (fp_layout.footprint or "").strip()
if not fp_name:
fp_name = (comp.footprint or "").strip()
cad = _cad_blob(
fp_name, comp.footprint or "", mpn, comp.value or "",
)
is_module = _is_module_land(cad)
declared = _declared_io_pins(pkg, cad)
ep_count = declared or pin_count
pcb = _join_keys_from_ids(pcb_ids, pin_count=ep_count, ds_keys=ds)
lib, lib_path = _lib_join_keys(
fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs,
)
sch = _join_keys_from_ids(
[(str(n), "") for n in comp.pins.keys()],
pin_count=ep_count,
ds_keys=ds,
)
out: list[Finding] = []
def _join_issue(
*,
miss: list[str],
extra: list[str],
finding: str,
facts: str,
requirement: str,
rec: str,
) -> Finding | None:
extra, _why = _drop_explainable_extras(
extra, missing=miss, is_module=is_module, declared=declared,
)
if not miss and not extra:
return None
return _pin_delta(
ref=ref, mpn=mpn,
finding=finding,
facts=facts,
requirement=requirement,
rec=rec,
)
if lib is not None:
miss_lib = sorted(ds - lib)
extra_lib = sorted(lib - ds)
if miss_lib or extra_lib:
rec = (
f"Change {ref}'s KiCad footprint library so pad names match "
f"the datasheet pintable (not a PCB pad-count guess)."
)
fnd = _join_issue(
miss=miss_lib, extra=extra_lib,
finding=(
f"{ref} datasheet pintable vs library footprint differ "
f"(missing on lib {miss_lib}, extra on lib {extra_lib})."
),
facts=(
f"pintable={sorted(ds)}; lib={sorted(lib)}; "
f"mod={lib_path!r}; pin_count unused."
),
requirement=(
"The KiCad library footprint must cover the datasheet "
"pintable by pin name. pin_count is not authority."
),
rec=rec,
)
if fnd:
out.append(fnd)
miss_emb = sorted(lib - pcb) if pcb else sorted(lib)
extra_emb = sorted(pcb - lib) if pcb else []
if pcb and (miss_emb or extra_emb):
rec = (
f"Replace {ref}'s embedded PCB footprint so it matches the "
f"named library footprint {fp_name}."
)
fnd = _join_issue(
miss=miss_emb, extra=extra_emb,
finding=(
f"{ref} library footprint vs PCB embedded pads differ "
f"(missing on PCB {miss_emb}, extra on PCB {extra_emb})."
),
facts=f"lib={sorted(lib)}; PCB={sorted(pcb)}; fp={fp_name!r}.",
requirement=(
"Embedded PCB pads must match the named .kicad_mod, "
"by pin name."
),
rec=rec,
)
if fnd:
out.append(fnd)
return out
if pcb:
miss = sorted(ds - pcb)
extra = sorted(pcb - ds)
if miss or extra:
rec = (
f"Fix {ref} pinout: PCB pad names vs datasheet pintable. "
f"Missing on PCB: {miss or '—'}; extra on PCB: {extra or '—'}."
)
fnd = _join_issue(
miss=miss, extra=extra,
finding=(
f"{ref} pintable vs PCB pads differ "
f"(missing {miss}, extra {extra})."
),
facts=(
f"pintable={sorted(ds)}; PCB={sorted(pcb)}; "
f"sch={sorted(sch)}; lib .kicad_mod not found."
),
requirement=(
"Each datasheet pin name must exist as a PCB pad. "
"Do not compare pin_count to pad cardinality."
),
rec=rec,
)
if fnd:
out.append(fnd)
return out
if sch and ds != sch:
miss = sorted(ds - sch)
extra = sorted(sch - ds)
if miss or extra:
rec = f"Align {ref} schematic pins with the datasheet pintable."
out.append(_pin_delta(
ref=ref, mpn=mpn,
finding=(
f"{ref} pintable vs schematic pins differ "
f"(missing {miss}, extra {extra})."
),
facts=f"pintable={sorted(ds)}; sch={sorted(sch)}.",
requirement="Schematic pin numbers must match the datasheet pintable.",
rec=rec,
))
return out
def _absmax_binds_pin(parameter: str, pin) -> bool:
"""Bind a voltage abs-max to the named pin, not every supply pin."""
param = parameter or ""
if _GND_DIFF_V.search(param) and not re.search(r"\bVIN\b|\bVDD\b|\bVCC\b", param, re.I):
return False
pin_u = f"{pin.name or ''} {pin.description or ''}".upper()
xtal = re.search(r"XTAL\d*", param, re.I)
if xtal:
return xtal.group(0).upper() in pin_u
rails = _RAIL_TOKEN.findall(param.upper())
rails = [r for r in rails if r not in {"VOLTAGE", "VSS", "VEE"}]
if not rails:
return False
name_u = (pin.name or "").upper()
return any(r == name_u or r in name_u.split("/") or name_u.startswith(r) for r in rails)
def _voltage_findings(
graph: DesignGraph,
ref: str,
comp: Component,
cons: ComponentConstraints | None,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
vrated = _cap_vrated(comp)
if vrated is not None:
return out # PE-DRT-* owns capacitor Vop/Vrated.
if not cons:
return out
for r in cons.absolute_maximum_ratings:
if r.max is None or not _rating_unit_v(r) or _NOT_V.search(r.parameter or ""):
continue
if not _V_PARAM.search(r.parameter or ""):
continue
for pin in cons.pintable:
if not _absmax_binds_pin(r.parameter or "", pin):
continue
net = graph.pin_net(ref, str(pin.number))
if not net:
continue
vop = _net_voltage(graph, net)
if vop is None:
continue
if vop > float(r.max):
rec = (
f"Lower '{net}' below {r.max:g} V or change {ref} "
f"({r.parameter} abs-max)."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-012",
finding=(
f"{ref} '{net}' is {vop:g} V; datasheet {r.parameter} "
f"abs-max is {r.max:g} {r.unit}."
),
facts=f"Vop={vop:g} V; abs-max {r.parameter}={r.max:g} {r.unit} p.{r.source_page}.",
requirement="Operating voltage must stay within absolute maximum ratings.",
rec=rec,
net=net,
))
return out
def _temp_findings(
ref: str,
comp: Component,
cons: ComponentConstraints | None,
) -> list[Finding]:
"""Operating temp vs abs-max only when both numbers exist (no Ta=25 invented)."""
if not cons:
return []
values = _specs_values(comp)
ta = _first(values, ("ta_max_c", "operating_temp_max_c", "tamb_max_c", "ta_max"))
if ta is None:
return []
out: list[Finding] = []
mpn = comp.mpn or ""
for r in cons.absolute_maximum_ratings:
if r.max is None or not _rating_unit_c(r):
continue
if not _T_PARAM.search(r.parameter or ""):
continue
if ta > float(r.max):
rec = f"Keep {ref} ambient/operating temperature ≤ {r.max:g} °C."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-013",
finding=(
f"{ref} operating temp {ta:g} °C exceeds {r.parameter} "
f"abs-max {r.max:g} {r.unit}."
),
facts=f"Ta_max={ta:g} °C from specs; abs-max {r.parameter}={r.max:g}.",
requirement="Specified operating temperature must not exceed abs-max T.",
rec=rec,
status="ERROR",
))
return out
def _current_findings(
graph: DesignGraph,
ref: str,
comp: Component,
cons: ComponentConstraints | None,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
i_load = _first(_specs_values(comp), _LOAD_KEYS)
irated = _ind_irated(comp)
if irated is not None and i_load is not None and i_load > irated:
rec = f"Replace {ref} with an inductor rated above {i_load:g} A."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-014",
finding=f"{ref} I_load={i_load:g} A exceeds current rating {irated:g} A.",
facts=f"I_load={i_load:g} A; Irated={irated:g} A.",
requirement="Load current must not exceed the inductor current rating.",
rec=rec,
))
if not cons or i_load is None:
return out
for r in cons.absolute_maximum_ratings:
if r.max is None or not _rating_unit_a(r):
continue
if not _I_PARAM.search(r.parameter or ""):
continue
if i_load > float(r.max):
rec = f"Cut the load on {ref} below {r.max:g} A ({r.parameter})."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-014",
finding=(
f"{ref} I_load={i_load:g} A exceeds abs-max {r.parameter} "
f"{r.max:g} A."
),
facts=f"I_load={i_load:g} A; abs-max {r.parameter}={r.max:g} A p.{r.source_page}.",
requirement="I_load must stay within absolute maximum current.",
rec=rec,
))
return out