Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
97c6f2bbeThis file is pinned to the selected release. Download the original to open it in your design tool.
"""Design-document -> op-list generator (``akcli ops from-intent``).
Field observation from agent-operated board work: an agent reading a datasheet
reliably produces a correct pin->net table, then loses that correctness while
hand-translating the table into op-list *geometry* (label collisions, pitch
trial-and-error, coordinate-coincidence near-shorts). Label-only connectivity
is proven viable — so the pin->net table becomes the authoring interface and
this module owns the geometry.
The input DESIGN document is a superset of the ``check --intent`` file
(``schemas/design-intent.schema.json``)::
{"protocol_version": 1,
"nets": {"RAIL_A": ["U1.3", "C1.1"], ...}, # intent format
"components": {"U1": {"lib_id": "...", "value": "...", ...}, ...},
"layout": {"origin": [1000, 1000], "columns": 3, "margin_mil": 100,
"group": "NAME", "no_connect_unlisted": true}}
Output is an ordinary op-list (``protocol_version`` 1) using ONLY the existing
vocabulary — ``place_component`` + label-on-pin ``add_net_label`` +
``add_no_connect`` — so it flows through the unchanged plan -> dry-apply ->
netdiff -> connectivity-verify pipeline. Components land in a uniform grid of
cells, each cell sized to the largest body + pin field + estimated label text
(the same stroke-font metrics ``check --layout`` judges the result by), ceiled
to the 50-mil writer grid.
Contract (the round-trip property): drawing the emitted op-list on a blank
sheet yields a netlist EQUAL to the design's ``nets`` table, and
``check --intent <design.json>`` over the drawn sheet reports zero findings.
Errors: shape problems raise ``BAD_CONFIG``/``PROTOCOL_MISMATCH`` (mirroring
``checks.intent.load``); semantic refusals — a pin addressed by NAME, wildcard
members, a REF missing from ``components``, one pin in two nets, coincident
stacked pins mapped to different nets, bus-vector net names — raise
``DESIGN_UNSUPPORTED`` (exit 6). An unknown ``lib_id`` raises the writer's
own ``SYMBOL_NOT_FOUND``. Deliberate MVP bounds, refused loudly rather than
guessed at: multi-unit symbols place ALL units (adjacent cells, unreferenced
units all-NC), and a multi-unit symbol with common (unit-0) pins is refused —
every placed unit would re-expose those pins and one label cannot honestly
cover N electrical points.
Determinism: identical output bytes for identical input content, regardless of
JSON dict key order — everything iterates in sorted order (components by
natural designator, nets by name, members by natural REF/pin).
"""
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from . import model, units
from .checks import intent as intent_mod
from .checks.layout import label_text_extent_mil
from .errors import fail
from .netbuild import expand_bus_vector
from .readers import kicad_lib
from .writers import geometry
PROTOCOL_VERSION = 1
GRID_MIL = 50.0
DEFAULT_ORIGIN: tuple[float, float] = (1000.0, 1000.0)
DEFAULT_MARGIN_MIL = 100.0
# Accepted keys (the schema-agreement test asserts these match
# schemas/design-intent.schema.json in both directions).
TOP_KEYS = frozenset({"protocol_version", "mode", "nets", "components",
"layout"})
COMPONENT_KEYS = frozenset({"lib_id", "value", "footprint", "rotation",
"mirror"})
LAYOUT_KEYS = frozenset({"origin", "columns", "margin_mil", "group",
"no_connect_unlisted"})
_VALID_ROTATIONS = frozenset({0, 90, 180, 270})
_VALID_MIRRORS = frozenset({"none", "x", "y"})
# KiCad bus label syntax a net NAME must not use: a vector label NAME[a..b]
# (netbuild.expand_bus_vector) or the group notation {..}. eeschema reads such
# label TEXT as a bus regardless of what it is anchored on, while akcli's
# netbuild treats it as a literal name off-bus — a sheet drawn with one would
# pass every akcli gate and mean something else in eeschema. Refused.
_BUS_GROUP_CHARS = frozenset("{}")
_NAT_RE = re.compile(r"(\d+)")
def _natkey(s: str) -> tuple:
"""Natural-sort key: digit runs compare numerically (``U2`` < ``U10``)."""
return tuple(int(part) if part.isdigit() else part
for part in _NAT_RE.split(s))
# --------------------------------------------------------------------------- #
# design document model + loader
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ComponentSpec:
"""One declared component: symbol identity + optional instance dressing."""
ref: str
lib_id: str
value: str | None = None
footprint: str | None = None
rotation: int = 0
mirror: str = "none"
@dataclass
class LayoutSpec:
"""Grid-layout hints; every field has the documented default."""
origin: tuple[float, float] = DEFAULT_ORIGIN
columns: int | None = None # None -> ceil(sqrt(placement count))
margin_mil: float = DEFAULT_MARGIN_MIL
group: str | None = None
no_connect_unlisted: bool = True
@dataclass
class Design:
"""A validated design document (nets half validated by ``checks.intent``)."""
nets: dict[str, intent_mod.NetSpec] = field(default_factory=dict)
components: dict[str, ComponentSpec] = field(default_factory=dict)
layout: LayoutSpec = field(default_factory=LayoutSpec)
def _snap50(v: float) -> float:
"""Nearest 50-mil multiple (the writer grid)."""
return round(float(v) / GRID_MIL) * GRID_MIL
def _ceil50(v: float) -> float:
"""Smallest 50-mil multiple >= ``v``."""
return math.ceil(float(v) / GRID_MIL) * GRID_MIL
def _num(v: object) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool)
def _is_point(v: object) -> bool:
return (isinstance(v, list) and len(v) == 2 and all(_num(c) for c in v))
def _parse_components(doc: dict, where: str) -> dict[str, ComponentSpec]:
raw = doc.get("components")
if not isinstance(raw, dict) or not raw:
fail("BAD_CONFIG",
f"{where}: 'components' must be a non-empty object of "
'{"REF": {"lib_id": "Lib:Symbol", ...}, ...}')
out: dict[str, ComponentSpec] = {}
for ref in sorted(map(str, raw), key=_natkey):
meta = raw[ref]
if not ref.strip():
fail("BAD_CONFIG", f"{where}: component REFs must be non-empty")
if "." in ref:
fail("BAD_CONFIG",
f"{where}: component REF {ref!r} must not contain '.' "
"(net members split on the first dot)")
if not isinstance(meta, dict):
fail("BAD_CONFIG",
f"{where}: component '{ref}': value must be an object "
'{"lib_id": ..., "value"?, "footprint"?, "rotation"?, '
'"mirror"?}')
extra = set(meta) - COMPONENT_KEYS
if extra:
fail("BAD_CONFIG",
f"{where}: component '{ref}': unknown key(s): "
f"{', '.join(sorted(map(str, extra)))} "
f"(expected {', '.join(sorted(COMPONENT_KEYS))})")
lib_id = meta.get("lib_id")
if not isinstance(lib_id, str) or not lib_id.strip():
fail("BAD_CONFIG",
f"{where}: component '{ref}': 'lib_id' must be a non-empty "
"string (e.g. 'Device:R')")
for key in ("value", "footprint"):
if key in meta and not isinstance(meta[key], str):
fail("BAD_CONFIG",
f"{where}: component '{ref}': {key!r} must be a string")
rotation = meta.get("rotation", 0)
if isinstance(rotation, bool) or rotation not in _VALID_ROTATIONS:
fail("BAD_CONFIG",
f"{where}: component '{ref}': rotation must be one of "
"0, 90, 180, 270")
mirror = meta.get("mirror")
if mirror is None:
mirror = "none"
if mirror not in _VALID_MIRRORS:
fail("BAD_CONFIG",
f"{where}: component '{ref}': mirror must be one of "
"none, x, y (or null)")
out[ref] = ComponentSpec(
ref=ref, lib_id=lib_id,
value=meta.get("value"), footprint=meta.get("footprint"),
rotation=int(rotation), mirror=str(mirror),
)
return out
def _parse_layout(doc: dict, where: str) -> LayoutSpec:
raw = doc.get("layout")
if raw is None:
return LayoutSpec()
if not isinstance(raw, dict):
fail("BAD_CONFIG", f"{where}: 'layout' must be an object")
extra = set(raw) - LAYOUT_KEYS
if extra:
fail("BAD_CONFIG",
f"{where}: layout: unknown key(s): "
f"{', '.join(sorted(map(str, extra)))} "
f"(expected {', '.join(sorted(LAYOUT_KEYS))})")
out = LayoutSpec()
if "origin" in raw:
if not _is_point(raw["origin"]):
fail("BAD_CONFIG",
f"{where}: layout.origin must be [x_mil, y_mil]")
out.origin = (_snap50(raw["origin"][0]), _snap50(raw["origin"][1]))
if "columns" in raw:
cols = raw["columns"]
if isinstance(cols, bool) or not isinstance(cols, int) or cols < 1:
fail("BAD_CONFIG",
f"{where}: layout.columns must be an integer >= 1")
out.columns = cols
if "margin_mil" in raw:
if not _num(raw["margin_mil"]) or raw["margin_mil"] < 0:
fail("BAD_CONFIG",
f"{where}: layout.margin_mil must be a number >= 0")
out.margin_mil = float(raw["margin_mil"])
if "group" in raw:
if not isinstance(raw["group"], str) or not raw["group"].strip():
fail("BAD_CONFIG",
f"{where}: layout.group must be a non-empty string")
out.group = raw["group"]
if "no_connect_unlisted" in raw:
if not isinstance(raw["no_connect_unlisted"], bool):
fail("BAD_CONFIG",
f"{where}: layout.no_connect_unlisted must be true or false")
out.no_connect_unlisted = raw["no_connect_unlisted"]
return out
def load_design(path: str | Path) -> Design:
"""Load + validate a design document (see the module docstring for shape).
The ``nets`` half is validated by ``checks.intent.load_doc`` — the SAME
loader ``check --intent`` runs, so a design file that generates is
guaranteed loadable as the assertion baseline. Shape errors raise
``BAD_CONFIG``/``PROTOCOL_MISMATCH``; ``FileNotFoundError`` propagates
(the CLI maps it to exit 4).
"""
p = Path(path)
text = p.read_text(encoding="utf-8")
try:
doc = json.loads(text)
except json.JSONDecodeError as exc:
fail("BAD_CONFIG", f"invalid design JSON in {p}: {exc}")
where = str(p)
if not isinstance(doc, dict):
fail("BAD_CONFIG", f"{where}: design root must be a JSON object")
# top-level key tolerance + protocol/mode/nets shape: the intent loader
# owns it (its _TOP_KEYS is the design superset)
spec = intent_mod.load_doc(doc, where)
components = _parse_components(doc, where)
layout = _parse_layout(doc, where)
return Design(nets=dict(spec.nets), components=components, layout=layout)
# --------------------------------------------------------------------------- #
# semantic validation (DESIGN_UNSUPPORTED refusals)
# --------------------------------------------------------------------------- #
def _check_net_names_and_members(design: Design) -> dict[tuple[str, str], str]:
"""Refuse wildcards / bus names / undeclared REFs / double-netted pins.
Returns the ``(ref, pin) -> net name`` map generation places labels from.
"""
pin_net: dict[tuple[str, str], str] = {}
for name in sorted(design.nets):
if expand_bus_vector(name) or any(c in name for c in _BUS_GROUP_CHARS):
fail("DESIGN_UNSUPPORTED",
f"net name {name!r} uses KiCad bus syntax (NAME[a..b] vector "
"or {..} group notation) — eeschema would read a label with "
"this text as a bus, not a net; name each member net "
"individually")
for m in design.nets[name].members:
if m.is_wildcard:
fail("DESIGN_UNSUPPORTED",
f"net '{name}': member {m.token!r} is a wildcard pattern "
"— wildcards are legal for `check --intent` assertions "
"but generation needs literal 'REF.PIN' members")
if m.ref not in design.components:
fail("DESIGN_UNSUPPORTED",
f"net '{name}': member {m.token!r} references REF "
f"{m.ref!r} which has no \"components\" entry — declare "
"it with a lib_id")
key = (m.ref, m.pin)
if key in pin_net and pin_net[key] != name:
fail("DESIGN_UNSUPPORTED",
f"pin {m.token!r} is listed in two nets "
f"({pin_net[key]!r} and {name!r}) — one pin belongs to "
"exactly one net")
pin_net[key] = name
return pin_net
def _check_pins_exist(
ref: str, comp: ComponentSpec, symdef: model.SymbolDef,
members: list[tuple[str, str]],
) -> None:
"""Refuse a member pin the symbol does not have (with a name->number hint)."""
numbers = {p.number for p in symdef.pins}
for net_name, pin in members:
if pin in numbers:
continue
by_name = sorted(
(p.number for p in symdef.pins if (p.name or "") == pin),
key=_natkey)
if by_name:
fail("DESIGN_UNSUPPORTED",
f"net '{net_name}': member '{ref}.{pin}' addresses a pin by "
f"NAME — use the pin NUMBER: '{ref}.{by_name[0]}' "
f"(pin {by_name[0]} is named {pin!r})")
fail("DESIGN_UNSUPPORTED",
f"net '{net_name}': component '{ref}' ({comp.lib_id}) has no "
f"pin {pin!r} (valid pins: "
f"{', '.join(sorted(numbers, key=_natkey))})")
def _coord_key(p: model.Pin) -> tuple[int, int]:
"""Symbol-local pin coordinate key robust to mm->mil float noise."""
return (round(p.x_mil * 10), round(p.y_mil * 10))
def _check_coincident_pins(
ref: str, symdef: model.SymbolDef, unit: int,
pin_net: dict[tuple[str, str], str],
) -> None:
"""Refuse two physically-coincident pins mapped to DIFFERENT nets.
Stacked pins (same symbol-local coordinate) share one electrical point on
the canvas; two different labels there would short the two nets while the
design table claims they are separate.
"""
by_coord: dict[tuple[int, int], list[model.Pin]] = {}
for p in kicad_lib.unit_pins(symdef, unit):
by_coord.setdefault(_coord_key(p), []).append(p)
for stacked in by_coord.values():
if len(stacked) < 2:
continue
netted = [(p.number, pin_net[(ref, p.number)])
for p in stacked if (ref, p.number) in pin_net]
nets = sorted({n for _num_, n in netted})
if len(nets) > 1:
pins = ", ".join(f"{ref}.{num} -> {net!r}"
for num, net in sorted(netted,
key=lambda t: _natkey(t[0])))
fail("DESIGN_UNSUPPORTED",
f"component '{ref}': physically-coincident (stacked) pins "
f"are mapped to different nets ({pins}) — they share one "
"point on the canvas and would short; put them on one net "
"or use a symbol without stacked pins")
# --------------------------------------------------------------------------- #
# geometry: per-placeable cells on a uniform grid
# --------------------------------------------------------------------------- #
@dataclass
class _Placeable:
"""One placed symbol instance (a unit of a component) and its cell needs."""
ref: str
comp: ComponentSpec
symdef: model.SymbolDef
unit: int
multi_unit: bool
# extent of body + pin tips around the symbol origin, world-oriented
# (rotation/mirror applied), in mils
box: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)
label_len: float = 0.0 # longest estimated label text, mils
cell_w: float = 0.0
cell_h: float = 0.0
off_x: float = 0.0 # symbol origin offset inside the cell
off_y: float = 0.0
def _instance_box_mil(
symdef: model.SymbolDef, unit: int, rotation: int, mirror: str,
) -> tuple[float, float, float, float]:
"""World-oriented extent (mils) of body + pin tips about the origin.
Uses the writer's canonical transform chain (``geometry``) — never a
reimplementation — with the instance at (0, 0).
"""
xs: list[float] = []
ys: list[float] = []
ext = kicad_lib.body_extent_mil(symdef, unit)
if ext is not None:
bx0, by0, bx1, by1 = geometry.world_box_from_extent(
ext, rotation, mirror, (0, 0))
xs += [units.nm_to_mil(bx0), units.nm_to_mil(bx1)]
ys += [units.nm_to_mil(by0), units.nm_to_mil(by1)]
for p in kicad_lib.unit_pins(symdef, unit):
wx, wy = geometry.transform_point(
(geometry.mil_to_nm(p.x_mil), -geometry.mil_to_nm(p.y_mil)),
rotation, mirror)
xs.append(units.nm_to_mil(wx))
ys.append(units.nm_to_mil(wy))
if not xs:
return (0.0, 0.0, 0.0, 0.0)
return (min(xs), min(ys), max(xs), max(ys))
def _size_cells(placeables: list[_Placeable], margin: float,
pin_net: dict[tuple[str, str], str]) -> None:
for pl in placeables:
unit_pin_nums = {p.number for p in kicad_lib.unit_pins(pl.symdef,
pl.unit)}
label_names = sorted({
net for (ref, pin), net in pin_net.items()
if ref == pl.ref and pin in unit_pin_nums})
pl.label_len = max(
(label_text_extent_mil(n)[0] for n in label_names), default=0.0)
pl.box = _instance_box_mil(pl.symdef, pl.unit,
pl.comp.rotation, pl.comp.mirror)
x0, y0, x1, y1 = pl.box
pad = pl.label_len + margin
# +GRID_MIL slack absorbs the ceil of the in-cell origin offset, so
# content provably stays inside the cell (see off_x/off_y below)
pl.cell_w = _ceil50((x1 - x0) + 2 * pad + GRID_MIL)
pl.cell_h = _ceil50((y1 - y0) + 2 * pad + GRID_MIL)
pl.off_x = _ceil50(pad - x0)
pl.off_y = _ceil50(pad - y0)
def _as_json_num(v: float) -> Any:
"""Ints where the value is integral, so the emitted JSON stays clean."""
return int(v) if float(v).is_integer() else float(v)
# --------------------------------------------------------------------------- #
# generation
# --------------------------------------------------------------------------- #
def generate(design: Design, libs: list[model.Library]) -> dict:
"""Emit a complete, valid op-list document for ``design``.
``libs`` are resolved symbol libraries (the caller coerces ``--symbols`` /
config paths through the same chain ``plan``/``draw`` use). Raises
``DESIGN_UNSUPPORTED`` for the documented refusals and propagates
``SYMBOL_NOT_FOUND`` for an unknown ``lib_id``. Pure and deterministic:
no I/O, identical output for identical design content.
"""
pin_net = _check_net_names_and_members(design)
# net members grouped per REF, for pin-existence checks (sorted for
# deterministic first-refusal order)
members_by_ref: dict[str, list[tuple[str, str]]] = {}
for name in sorted(design.nets):
for m in sorted(design.nets[name].members,
key=lambda m: (_natkey(m.ref), _natkey(m.pin))):
members_by_ref.setdefault(m.ref, []).append((name, m.pin))
placeables: list[_Placeable] = []
for ref in sorted(design.components, key=_natkey):
comp = design.components[ref]
symdef = kicad_lib.resolve(comp.lib_id, libs) # SYMBOL_NOT_FOUND
_check_pins_exist(ref, comp, symdef, members_by_ref.get(ref, []))
part_count = max(1, symdef.part_count or 1)
if part_count > 1 and any(p.owner_part_id == 0 for p in symdef.pins):
fail("DESIGN_UNSUPPORTED",
f"component '{ref}' ({comp.lib_id}): a multi-unit symbol "
"with common (unit-0) pins is not supported — every placed "
"unit re-exposes those pins as separate electrical points; "
"author this part with a hand-written op-list")
for unit in range(1, part_count + 1):
_check_coincident_pins(ref, symdef, unit, pin_net)
placeables.append(_Placeable(ref=ref, comp=comp, symdef=symdef,
unit=unit,
multi_unit=part_count > 1))
layout = design.layout
_size_cells(placeables, layout.margin_mil, pin_net)
cell_w = max((pl.cell_w for pl in placeables), default=GRID_MIL)
cell_h = max((pl.cell_h for pl in placeables), default=GRID_MIL)
columns = layout.columns or max(1, math.ceil(math.sqrt(len(placeables))
or 1))
grouped = layout.group is not None
base_x, base_y = (0.0, 0.0) if grouped else layout.origin
ops: list[dict] = []
for i, pl in enumerate(placeables):
col, row = i % columns, i // columns
op: dict = {
"op": "place_component",
"lib_id": pl.comp.lib_id,
"designator": pl.ref,
"x_mil": _as_json_num(base_x + col * cell_w + pl.off_x),
"y_mil": _as_json_num(base_y + row * cell_h + pl.off_y),
}
if pl.comp.rotation:
op["rotation"] = pl.comp.rotation
if pl.comp.mirror != "none":
op["mirror"] = pl.comp.mirror
if pl.multi_unit:
op["unit"] = pl.unit
# value/footprint go on EVERY unit instance (each placed symbol
# carries its own properties; eeschema keeps them in sync)
if pl.comp.value is not None:
op["value"] = pl.comp.value
if pl.comp.footprint is not None:
op["footprint"] = pl.comp.footprint
if grouped:
op["group"] = layout.group
ops.append(op)
for name in sorted(design.nets):
for m in sorted(design.nets[name].members,
key=lambda m: (_natkey(m.ref), _natkey(m.pin))):
op = {"op": "add_net_label", "name": name, "at": m.token}
if grouped:
op["group"] = layout.group
ops.append(op)
if layout.no_connect_unlisted:
ops.extend(_no_connect_ops(placeables, pin_net, layout.group))
doc: dict = {
"protocol_version": PROTOCOL_VERSION,
"target_format": "kicad",
# provenance annotation: `_`-prefixed keys are validator-ignored
"_generator": "akcli ops from-intent",
}
if grouped:
ox, oy = layout.origin
doc["groups"] = {layout.group: {
"origin": [_as_json_num(ox), _as_json_num(oy)]}}
doc["ops"] = ops
return doc
def _no_connect_ops(
placeables: list[_Placeable],
pin_net: dict[tuple[str, str], str],
group: str | None,
) -> list[dict]:
"""``add_no_connect`` for every unlisted pin of every placed instance.
Coincident-pin care: a pin stacked on a NETTED pin of the same instance is
electrically that net already — an NC marker there would contradict the
live connection, so it is skipped; a stack of only-unlisted pins gets ONE
marker (lowest pin number), not N stacked markers.
"""
ops: list[dict] = []
for pl in placeables:
netted_coords = set()
pins = sorted(kicad_lib.unit_pins(pl.symdef, pl.unit),
key=lambda p: _natkey(p.number))
for p in pins:
if (pl.ref, p.number) in pin_net:
netted_coords.add(_coord_key(p))
seen_nc_coords: set[tuple[int, int]] = set()
for p in pins:
if (pl.ref, p.number) in pin_net:
continue
coord = _coord_key(p)
if coord in netted_coords or coord in seen_nc_coords:
continue
seen_nc_coords.add(coord)
op = {"op": "add_no_connect", "pin": f"{pl.ref}.{p.number}"}
if group is not None:
op["group"] = group
ops.append(op)
return ops