diff --git a/chipflow/packaging/__init__.py b/chipflow/packaging/__init__.py index c6d64e9e..6aff2bef 100644 --- a/chipflow/packaging/__init__.py +++ b/chipflow/packaging/__init__.py @@ -75,6 +75,13 @@ from .utils import ( load_pinlock, lock_pins, + swap_pins, +) + +# Renderers +from .render import ( + render_text, + render_svg, ) # CLI commands @@ -129,6 +136,10 @@ # Utilities 'load_pinlock', 'lock_pins', + 'swap_pins', + # Renderers + 'render_text', + 'render_svg', # CLI 'PinCommand', ] diff --git a/chipflow/packaging/commands.py b/chipflow/packaging/commands.py index 5787f9ae..770df588 100644 --- a/chipflow/packaging/commands.py +++ b/chipflow/packaging/commands.py @@ -5,8 +5,10 @@ import inspect import logging +from pathlib import Path -from .utils import lock_pins +from .utils import lock_pins, swap_pins, load_pinlock +from .render import render_text, render_svg logger = logging.getLogger(__name__) @@ -36,10 +38,30 @@ def build_cli_parser(self, parser): parser: argparse parser to add subcommands to """ assert inspect.getdoc(self.lock) is not None + assert inspect.getdoc(self.swap) is not None + assert inspect.getdoc(self.show) is not None action_argument = parser.add_subparsers(dest="action") + action_argument.add_parser( "lock", help=inspect.getdoc(self.lock).splitlines()[0]) # type: ignore + swap_parser = action_argument.add_parser( + "swap", help=inspect.getdoc(self.swap).splitlines()[0]) # type: ignore + swap_parser.add_argument("pin_a", type=int, help="first pin number") + swap_parser.add_argument("pin_b", type=int, help="second pin number") + + show_parser = action_argument.add_parser( + "show", help=inspect.getdoc(self.show).splitlines()[0]) # type: ignore + show_parser.add_argument( + "--format", "-f", + choices=("text", "svg"), default="text", + help="output format (default: text)" + ) + show_parser.add_argument( + "--output", "-o", type=Path, default=None, + help="write to file instead of stdout" + ) + def run_cli(self, args): """ Execute the CLI command. @@ -50,6 +72,10 @@ def run_cli(self, args): logger.debug(f"command {args}") if args.action == "lock": self.lock() + elif args.action == "swap": + self.swap(args.pin_a, args.pin_b) + elif args.action == "show": + self.show(format=args.format, output=args.output) def lock(self): """ @@ -58,3 +84,31 @@ def lock(self): Will attempt to reuse previous pin positions. """ lock_pins(self.config) + + def swap(self, pin_a: int, pin_b: int): + """ + Swap two pin assignments in pins.lock. + + Both pins must currently be allocated to user ports; bringup + pins (clock, reset, power, heartbeat, JTAG) are package-defined + and cannot be swapped. + """ + swap_pins(pin_a, pin_b) + + def show(self, format: str = "text", output: Path | None = None): + """ + Show the current pin allocation from pins.lock. + + Text format works for any package type. SVG format renders a + package layout for perimeter packages (Quad/Block). + """ + lockfile = load_pinlock() + if format == "svg": + rendered = render_svg(lockfile) + else: + rendered = render_text(lockfile) + if output is not None: + output.write_text(rendered) + print(f"Wrote {output}") + else: + print(rendered, end='') diff --git a/chipflow/packaging/render.py b/chipflow/packaging/render.py new file mode 100644 index 00000000..6ae775a7 --- /dev/null +++ b/chipflow/packaging/render.py @@ -0,0 +1,230 @@ +# SPDX-License-Identifier: BSD-2-Clause +""" +Renderers for ``pins.lock`` — text table and SVG layout. + +Used by ``chipflow pin show``. The text view works for any package +type. The SVG view is currently implemented for perimeter-pin packages +(:class:`QuadPackageDef`, :class:`BlockPackageDef`); other types raise +:class:`ChipFlowError` and are expected to fall back to the text view. +""" + +from html import escape +from typing import Dict, Tuple + +from .. import ChipFlowError +from .lockfile import LockFile +from .standard import BlockPackageDef, QuadPackageDef + + +def _walk_ports(lockfile: LockFile): + """Yield (component, interface, port_name, bit, pin, port_desc) for + every assigned pin in the lockfile.""" + for cname, comp in lockfile.port_map.ports.items(): + for iname, intf in comp.items(): + for pname, port in intf.items(): + if port.pins is None: + continue + for bit, pin in enumerate(port.pins): + yield cname, iname, pname, bit, pin, port + + +def _slot_label(cname: str, iname: str, pname: str, bit: int, width: int) -> str: + if width > 1: + return f"{cname}.{iname}.{pname}[{bit}]" + return f"{cname}.{iname}.{pname}" + + +def render_text(lockfile: LockFile) -> str: + """Render the pin allocation as a sorted text table.""" + rows = [] + for cname, iname, pname, bit, pin, port in _walk_ports(lockfile): + label = _slot_label(cname, iname, pname, bit, len(port.pins or [])) + direction = port.iomodel.get('direction', '') + # io.Direction uses 'i'/'o'/'io' as its short value form. + dir_str = getattr(direction, 'value', str(direction)) + rows.append((pin, label, port.type, dir_str)) + + def _sortkey(row): + p = row[0] + return (0, p) if isinstance(p, int) else (1, repr(p)) + + rows.sort(key=_sortkey) + + out = ["{:<8} {:<8} {:<5} {}".format("PIN", "TYPE", "DIR", "PORT")] + for pin, label, kind, direction in rows: + out.append("{:<8} {:<8} {:<5} {}".format(str(pin), kind, direction, label)) + return "\n".join(out) + "\n" + + +def _pin_to_perimeter_position(pin: int, width: int, height: int) -> Tuple[str, int]: + """Map a perimeter pin number to ``(side, slot)``. + + Convention shared by :class:`QuadPackageDef` and + :class:`BlockPackageDef`: pin 1 sits at the top of the West edge, + numbering proceeds counter-clockwise. ``slot`` is 0-indexed along + the side in the direction of numbering (top→bottom on W/E in + natural progression, left→right on S, right→left on N). + """ + p = pin + if p <= height: + return 'W', p - 1 + p -= height + if p <= width: + return 'S', p - 1 + p -= width + if p <= height: + return 'E', p - 1 + p -= height + return 'N', p - 1 + + +def _perimeter_svg(width: int, height: int, + pin_labels: Dict[int, str], title: str) -> str: + """Render a perimeter package layout as standalone SVG. + + N/S labels are rotated and extend OUTWARD from the package; W/E + labels are horizontal and extend outward from the side. Canvas + padding scales with the longest label so nothing gets clipped. + """ + pitch = 30 + # Approximate per-character width for 11px monospace. + char_px = 6.6 + longest_label = max((len(s) for s in pin_labels.values()), default=4) + label_run = int(longest_label * char_px) + 16 + + # Side margins: enough for horizontal W/E labels + a small gap. + pad_x = label_run + 40 + # Top/bottom margins: enough for rotated N/S labels + title strip. + pad_y = label_run + 60 + + box_w = width * pitch + box_h = height * pitch + svg_w = box_w + 2 * pad_x + svg_h = box_h + 2 * pad_y + + parts = [] + parts.append( + f'' + ) + parts.append( + '' + ) + parts.append( + f'{escape(title)}' + ) + parts.append( + f'' + ) + + total = 2 * (width + height) + for pin in range(1, total + 1): + side, slot = _pin_to_perimeter_position(pin, width, height) + label = escape(pin_labels.get(pin, '—')) # em dash for empty + + if side == 'W': + cx, cy = pad_x, pad_y + (slot + 0.5) * pitch + parts.append( + f'' + ) + parts.append( + f'{pin}' + ) + parts.append( + f'{label}' + ) + elif side == 'E': + cx = pad_x + box_w + cy = pad_y + box_h - (slot + 0.5) * pitch + parts.append( + f'' + ) + parts.append( + f'{pin}' + ) + parts.append( + f'{label}' + ) + elif side == 'N': + # rotate(-90) + text-anchor="start": glyphs extend in +x + # pre-rotation; after a -90° (CCW) rotation about the + # anchor, they extend upward — away from the block top. + cx, cy = pad_x + box_w - (slot + 0.5) * pitch, pad_y + parts.append( + f'' + ) + parts.append( + f'{pin}' + ) + anchor_x, anchor_y = cx + 3, cy - 28 + parts.append( + f'{label}' + ) + else: # 'S' + # rotate(90) + text-anchor="start": glyphs extend in +x + # pre-rotation; after a +90° (CW) rotation about the anchor, + # they extend downward — away from the block bottom. + cx = pad_x + (slot + 0.5) * pitch + cy = pad_y + box_h + parts.append( + f'' + ) + parts.append( + f'{pin}' + ) + anchor_x, anchor_y = cx - 3, cy + 28 + parts.append( + f'{label}' + ) + + parts.append('') + return '\n'.join(parts) + '\n' + + +def render_svg(lockfile: LockFile) -> str: + """Render the pin allocation as SVG. + + Currently supports :class:`QuadPackageDef` and + :class:`BlockPackageDef`. Other package types raise + :class:`ChipFlowError`. + """ + pkg = lockfile.package.package_type + if not isinstance(pkg, (QuadPackageDef, BlockPackageDef)): + raise ChipFlowError( + f"SVG output is not yet supported for " + f"{type(pkg).__name__}. Use --format text." + ) + + pin_labels: Dict[int, str] = {} + for cname, iname, pname, bit, pin, port in _walk_ports(lockfile): + if not isinstance(pin, int): + continue + pin_labels[pin] = _slot_label( + cname, iname, pname, bit, len(port.pins or []) + ) + + title = f"{pkg.name} ({type(pkg).__name__} {pkg.width}×{pkg.height})" + return _perimeter_svg(pkg.width, pkg.height, pin_labels, title) diff --git a/chipflow/packaging/utils.py b/chipflow/packaging/utils.py index f56dac4f..4ebdf1aa 100644 --- a/chipflow/packaging/utils.py +++ b/chipflow/packaging/utils.py @@ -115,3 +115,79 @@ def lock_pins(config: Optional['Config'] = None) -> None: with open(lockfile, 'w') as f: f.write(newlock.model_dump_json(indent=2, serialize_as_any=True)) + + +def swap_pins(pin_a: int, pin_b: int) -> None: + """ + Swap two pin assignments in the current ``pins.lock``. + + Bringup pins (clock, reset, power, heartbeat, JTAG — everything + under the ``_core`` component) are package-defined and cannot be + swapped. Both inputs must currently be allocated to user ports of a + package that uses integer pin numbers (Quad / Block / Openframe). + + Args: + pin_a: First pin number. + pin_b: Second pin number. + + Raises: + ChipFlowError: ``pins.lock`` is missing or malformed; pins are + identical; either pin is not allocated; either pin lives in + the bringup ring; or the package uses non-integer pins. + """ + if pin_a == pin_b: + raise ChipFlowError(f"Cannot swap pin {pin_a} with itself.") + + chipflow_root = ensure_chipflow_root() + lockfile_path = Path(chipflow_root, 'pins.lock') + lockfile = load_pinlock() + + def find_slot(pin): + for cname, comp in lockfile.port_map.ports.items(): + for iname, intf in comp.items(): + for pname, port in intf.items(): + if port.pins is None: + continue + for i, p in enumerate(port.pins): + if not isinstance(p, int): + raise ChipFlowError( + "swap is currently only supported for " + "packages with integer pin numbers " + "(Quad / Block / Openframe). This " + f"lockfile uses pins of type " + f"{type(p).__name__}." + ) + if p == pin: + return cname, iname, pname, i + return None + + loc_a = find_slot(pin_a) + loc_b = find_slot(pin_b) + + if loc_a is None: + raise ChipFlowError(f"Pin {pin_a} is not allocated in pins.lock.") + if loc_b is None: + raise ChipFlowError(f"Pin {pin_b} is not allocated in pins.lock.") + + for loc, pin in ((loc_a, pin_a), (loc_b, pin_b)): + if loc[0] == "_core": + raise ChipFlowError( + f"Pin {pin} is a bringup pin ({loc[1]}.{loc[2]}); " + "bringup pins are package-defined and cannot be swapped." + ) + + ca, ia, na, idx_a = loc_a + cb, ib, nb, idx_b = loc_b + port_a = lockfile.port_map.ports[ca][ia][na] + port_b = lockfile.port_map.ports[cb][ib][nb] + assert port_a.pins is not None and port_b.pins is not None + port_a.pins[idx_a] = pin_b + port_b.pins[idx_b] = pin_a + + with open(lockfile_path, 'w') as f: + f.write(lockfile.model_dump_json(indent=2, serialize_as_any=True)) + + from .render import _slot_label + label_a = _slot_label(ca, ia, na, idx_a, len(port_a.pins)) + label_b = _slot_label(cb, ib, nb, idx_b, len(port_b.pins)) + print(f"Swapped pin {pin_a} ({label_a}) with pin {pin_b} ({label_b}).") diff --git a/tests/test_pin_swap_show.py b/tests/test_pin_swap_show.py new file mode 100644 index 00000000..ee74905d --- /dev/null +++ b/tests/test_pin_swap_show.py @@ -0,0 +1,206 @@ +# SPDX-License-Identifier: BSD-2-Clause +"""Tests for ``chipflow pin swap`` and ``chipflow pin show``.""" + +import os +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path + +from amaranth.lib import io + +from chipflow import ChipFlowError +from chipflow.config.models import Process +from chipflow.packaging.lockfile import LockFile, Package +from chipflow.packaging.port_desc import PortMap, PortDesc +from chipflow.packaging.standard import BlockPackageDef, BareDiePackageDef +from chipflow.packaging.utils import swap_pins, load_pinlock +from chipflow.packaging.render import render_text, render_svg + + +def _build_block_lockfile() -> LockFile: + """A 4×4 BlockPackageDef with bringup (clk=1, rst_n=2) plus two + user pins on a soc.uart interface (tx=3, rx[0..1]=4,5).""" + pkg = BlockPackageDef(name="block", width=4, height=4) + port_map = PortMap(ports={ + "_core": {"bringup_pins": { + "clk": PortDesc( + type='clock', pins=[1], port_name='clk', + iomodel={"width": 1, "direction": io.Direction.Input, + "clock_domain": "sync"}, + ), + "rst_n": PortDesc( + type='reset', pins=[2], port_name='rst_n', + iomodel={"width": 1, "direction": io.Direction.Input, + "clock_domain": "sync", "invert": True}, + ), + }}, + "soc": {"uart": { + "tx": PortDesc( + type='io', pins=[3], port_name='tx', + iomodel={"width": 1, "direction": io.Direction.Output}, + ), + "rx": PortDesc( + type='io', pins=[4, 5], port_name='rx', + iomodel={"width": 2, "direction": io.Direction.Input}, + ), + }}, + }) + return LockFile( + process=Process.SKY130, + package=Package(package_type=pkg), + port_map=port_map, + metadata={}, + ) + + +@contextmanager +def _chipflow_root_with(lockfile: LockFile): + """Write the lockfile into a fresh tmpdir set as CHIPFLOW_ROOT, + flushing the cached root so loaders pick the new one up.""" + from chipflow.utils import ensure_chipflow_root + old = os.environ.get('CHIPFLOW_ROOT') + with tempfile.TemporaryDirectory() as tmpdir: + os.environ['CHIPFLOW_ROOT'] = tmpdir + if hasattr(ensure_chipflow_root, 'root'): + delattr(ensure_chipflow_root, 'root') + path = Path(tmpdir) / 'pins.lock' + path.write_text(lockfile.model_dump_json(indent=2, serialize_as_any=True)) + try: + yield path + finally: + if old is not None: + os.environ['CHIPFLOW_ROOT'] = old + else: + os.environ.pop('CHIPFLOW_ROOT', None) + if hasattr(ensure_chipflow_root, 'root'): + delattr(ensure_chipflow_root, 'root') + + +class SwapPinsTestCase(unittest.TestCase): + def test_swap_two_user_pins(self): + with _chipflow_root_with(_build_block_lockfile()): + swap_pins(3, 4) + reloaded = load_pinlock() + ports = reloaded.port_map.ports['soc']['uart'] + # tx had pin 3 → now 4; rx[0] had pin 4 → now 3. + self.assertEqual(ports['tx'].pins, [4]) + self.assertEqual(ports['rx'].pins, [3, 5]) + + def test_swap_within_multi_bit_port(self): + """Two bits of the same multi-bit port can be swapped.""" + with _chipflow_root_with(_build_block_lockfile()): + swap_pins(4, 5) + reloaded = load_pinlock() + self.assertEqual( + reloaded.port_map.ports['soc']['uart']['rx'].pins, [5, 4] + ) + + def test_swap_with_self_rejected(self): + with _chipflow_root_with(_build_block_lockfile()): + with self.assertRaises(ChipFlowError) as cm: + swap_pins(3, 3) + self.assertIn("itself", str(cm.exception)) + + def test_swap_unallocated_pin_rejected(self): + with _chipflow_root_with(_build_block_lockfile()): + with self.assertRaises(ChipFlowError) as cm: + swap_pins(3, 99) + self.assertIn("99", str(cm.exception)) + self.assertIn("not allocated", str(cm.exception)) + + def test_swap_bringup_pin_rejected(self): + """clk lives in _core.bringup_pins → can't swap with a user pin.""" + with _chipflow_root_with(_build_block_lockfile()): + with self.assertRaises(ChipFlowError) as cm: + swap_pins(1, 3) + self.assertIn("bringup", str(cm.exception)) + + def test_swap_persists_to_disk(self): + """After swap, the file on disk reflects the new mapping.""" + with _chipflow_root_with(_build_block_lockfile()) as path: + swap_pins(3, 5) + text = path.read_text() + # Pin 3 should now belong to rx[1], pin 5 to tx — easiest + # check is to load and inspect. + after = LockFile.model_validate_json(text) + self.assertEqual(after.port_map.ports['soc']['uart']['tx'].pins, [5]) + self.assertEqual( + after.port_map.ports['soc']['uart']['rx'].pins, [4, 3] + ) + + def test_swap_unsupported_pin_type(self): + """Non-int pin types (e.g. BareDie's (Side, idx)) are rejected + with a clear message.""" + # Build a minimal BareDie lockfile by hand. + pkg = BareDiePackageDef(name="bare", width=4, height=4) + port_map = PortMap(ports={ + "soc": {"uart": { + "tx": PortDesc( + type='io', pins=[("N", 0)], port_name='tx', + iomodel={"width": 1, "direction": io.Direction.Output}, + ), + }}, + }) + lockfile = LockFile( + process=Process.SKY130, + package=Package(package_type=pkg), + port_map=port_map, + metadata={}, + ) + with _chipflow_root_with(lockfile): + with self.assertRaises(ChipFlowError) as cm: + swap_pins(1, 2) + self.assertIn("integer", str(cm.exception)) + + +class RenderTextTestCase(unittest.TestCase): + def test_text_lists_all_pins_sorted(self): + text = render_text(_build_block_lockfile()) + lines = text.strip().splitlines() + self.assertEqual(lines[0].split()[0], "PIN") + # Pin column of body rows, in order + pin_col = [line.split()[0] for line in lines[1:]] + self.assertEqual(pin_col, ['1', '2', '3', '4', '5']) + + def test_text_includes_port_paths(self): + text = render_text(_build_block_lockfile()) + self.assertIn("_core.bringup_pins.clk", text) + self.assertIn("_core.bringup_pins.rst_n", text) + self.assertIn("soc.uart.tx", text) + self.assertIn("soc.uart.rx[0]", text) + self.assertIn("soc.uart.rx[1]", text) + + +class RenderSvgTestCase(unittest.TestCase): + def test_svg_basic_shape(self): + svg = render_svg(_build_block_lockfile()) + self.assertTrue(svg.startswith('', svg) + # Contains user port labels and bringup labels. + self.assertIn('_core.bringup_pins.clk', svg) + self.assertIn('soc.uart.tx', svg) + + def test_svg_unallocated_slots_are_em_dashed(self): + """A 4×4 block has 16 perimeter slots; we allocated only 5 → + the rest should render as '—'.""" + svg = render_svg(_build_block_lockfile()) + # At least one em-dash placeholder for the empty slots. + self.assertIn('—', svg) + + def test_svg_unsupported_package_type(self): + """BareDie has no SVG renderer yet; should raise cleanly.""" + pkg = BareDiePackageDef(name="bare", width=4, height=4) + lockfile = LockFile( + process=Process.SKY130, + package=Package(package_type=pkg), + port_map=PortMap(), + metadata={}, + ) + with self.assertRaises(ChipFlowError) as cm: + render_svg(lockfile) + self.assertIn("BareDie", str(cm.exception)) + + +if __name__ == "__main__": + unittest.main()