diff --git a/README.md b/README.md index 4bac97a..f423d9f 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ print(f"Path delay: {delays[0].nominal.max}") ## CLI Reference -The `sdf-toolkit` command provides 19 subcommands for comprehensive SDF manipulation: +The `sdf-toolkit` command provides 20 subcommands for comprehensive SDF manipulation: ### File I/O @@ -148,6 +148,7 @@ The `sdf-toolkit` command provides 19 subcommands for comprehensive SDF manipula | `merge` | Combine multiple SDF files | | `normalize` | Convert all delays to target timescale | | `annotate` | Generate Verilog specify blocks | +| `port-to-interconnect` | Rewrite PORT delays as INTERCONNECT using a netlist | ### Visualization @@ -256,6 +257,12 @@ sdf-toolkit normalize design.sdf --target 1ns --format sdf > design_ns.sdf # Annotate Verilog with specify blocks sdf-toolkit annotate design.sdf cells.v -o annotated_cells.v +# Rewrite PORT (sink-only) delays as INTERCONNECT (source -> sink) delays. +# The driver of each PORT load is recovered from the gate-level netlist via +# Yosys, so back-ends that emit PORT (e.g. Libero) become consumable by tools +# that only understand INTERCONNECT. +sdf-toolkit port-to-interconnect design.sdf netlist.v -o design_interconnect.sdf + # Decompose unknown delay sdf-toolkit decompose \ --total '{"nominal": {"min": null, "avg": null, "max": 5.0}}' \ diff --git a/src/sdf_toolkit/cli.py b/src/sdf_toolkit/cli.py index 36c9cf2..a303972 100644 --- a/src/sdf_toolkit/cli.py +++ b/src/sdf_toolkit/cli.py @@ -569,6 +569,54 @@ def annotate( typer.echo(f"Written to {output}") +@app.command(name="port-to-interconnect") +def port_to_interconnect_cmd( + sdf_file: Annotated[ + Path, + typer.Argument(help="Path to the SDF file containing PORT delays."), + ], + verilog_file: Annotated[ + Path, + typer.Argument(help="Path to the gate-level Verilog netlist."), + ], + output: Annotated[ + Path | None, + typer.Option("--output", "-o", help="Output file (default: stdout)."), + ] = None, + timescale: Annotated[ + str, + typer.Option("--timescale", "-t", help="Timescale when the SDF sets none."), + ] = "1ps", + top_module: Annotated[ + str | None, + typer.Option("--top", help="Top module name (default: SDF DESIGN)."), + ] = None, +) -> None: + """Rewrite PORT (sink-only) delays as INTERCONNECT (source->sink) delays. + + The driver of each PORT load is recovered from the Verilog netlist via Yosys. + """ + from sdf_toolkit.io.annotate import parse_yosys_json, run_yosys + from sdf_toolkit.transform.interconnect import ( + DriverResolutionError, + port_to_interconnect, + ) + + sdf = _load_sdf(sdf_file) + design = parse_yosys_json(run_yosys(verilog_file)) + try: + result = port_to_interconnect(sdf, design, top_module=top_module) + except DriverResolutionError as exc: + raise typer.BadParameter(str(exc)) from exc + + text = sdf_emit(result, timescale=result.header.timescale or timescale) + if output is not None: + output.write_text(text) + typer.echo(f"Written to {output}") + else: + typer.echo(text) + + @app.command() def normalize( sdf_file: Annotated[ @@ -731,7 +779,10 @@ def query_cmd( sdf = _load_sdf(sdf_file) - entry_types = [EntryType(e) for e in entry_type] if entry_type else None + try: + entry_types = [EntryType(e) for e in entry_type] if entry_type else None + except ValueError as exc: + raise typer.BadParameter(str(exc), param_hint="--entry-type") from exc result = query( sdf, @@ -863,10 +914,15 @@ def merge_cmd( """Merge two or more SDF files into one.""" from sdf_toolkit.transform.merge import ConflictStrategy, merge + try: + conflict_strategy = ConflictStrategy(strategy) + except ValueError as exc: + raise typer.BadParameter(str(exc), param_hint="--strategy") from exc + sdf_files = [_load_sdf(f) for f in files] result = merge( sdf_files, - strategy=ConflictStrategy(strategy), + strategy=conflict_strategy, target_timescale=target_timescale, ) diff --git a/src/sdf_toolkit/core/model.py b/src/sdf_toolkit/core/model.py index 288fbb5..b3629c0 100644 --- a/src/sdf_toolkit/core/model.py +++ b/src/sdf_toolkit/core/model.py @@ -7,8 +7,37 @@ from typing import Any, Literal -class EntryType(StrEnum): - """Types of SDF timing entries.""" +class CaseInsensitiveStrEnum(StrEnum): + """A ``StrEnum`` whose value lookup ignores case. + + SDF and Verilog keywords are conventionally written upper case (``IOPATH``, + ``PORT``) while the member values here are lower case. This lets + ``EntryType("IOPATH")`` resolve instead of raising, so CLI options that take + these names work regardless of the case the user types. + """ + + @classmethod + def _missing_(cls, value: object) -> "CaseInsensitiveStrEnum | None": + """Resolve a value case-insensitively, or return None if unknown.""" + if isinstance(value, str): + lowered = value.lower() + for member in cls: + if member.value == lowered: + return member + return None + + +class EntryType(CaseInsensitiveStrEnum): + """Types of SDF timing entries. + + Member values are lower case but lookup is case-insensitive, so the + upper-case spellings used in SDF files resolve directly: + + >>> EntryType("IOPATH") is EntryType.IOPATH + True + >>> EntryType("Port") is EntryType.PORT + True + """ PORT = "port" INTERCONNECT = "interconnect" diff --git a/src/sdf_toolkit/transform/__init__.py b/src/sdf_toolkit/transform/__init__.py index 526deda..e6cf0cd 100644 --- a/src/sdf_toolkit/transform/__init__.py +++ b/src/sdf_toolkit/transform/__init__.py @@ -1,12 +1,19 @@ """Transform modules for SDF timing data manipulation.""" +from sdf_toolkit.transform.interconnect import ( + DriverResolutionError, + port_to_interconnect, +) from sdf_toolkit.transform.merge import ConflictStrategy, merge from sdf_toolkit.transform.normalize import normalize_delays __all__ = [ # merge "ConflictStrategy", + # interconnect + "DriverResolutionError", "merge", # normalize "normalize_delays", + "port_to_interconnect", ] diff --git a/src/sdf_toolkit/transform/interconnect.py b/src/sdf_toolkit/transform/interconnect.py new file mode 100644 index 0000000..1e9274e --- /dev/null +++ b/src/sdf_toolkit/transform/interconnect.py @@ -0,0 +1,290 @@ +"""Rewrite PORT interconnect delays as INTERCONNECT delays. + +Some back-ends (Microchip Libero among them) express routing delay with the SDF +``(PORT ...)`` construct, which names only the load pin and leaves the +driver implicit. Tools that consume only ``(INTERCONNECT ...)``, +which names both endpoints, cannot use those delays. This module rewrites each +PORT delay into the equivalent INTERCONNECT by recovering the driver from the +gate-level netlist. + +The driver is read from connectivity, never guessed. Yosys parses the netlist +into bit-level nets (see :mod:`sdf_toolkit.io.annotate`); the SDF's own IOPATH +entries identify which cell pins are outputs; top-level port directions identify +which ports drive the fabric. A cell the SDF does not time, whose pin roles are +therefore unknown, is resolved by elimination: on a single-driver net whose every +other endpoint is a known sink, the one remaining endpoint is the driver. Any pin +that does not resolve to exactly one driver raises ``DriverResolutionError``; +nothing is dropped or defaulted. + +The transform is vendor- and device-neutral: it keys off SDF and netlist +structure only, never specific cell or tool names. It assumes a flat top-level +netlist (the PORT-bearing instances are direct children of the top module, as in +a back-annotated post-route netlist), scalar PORT pins, and single-driver nets. +Each unmet assumption raises rather than producing a wrong result. +""" + +import copy +from typing import NamedTuple + +from sdf_toolkit.core.builder import make_interconnect +from sdf_toolkit.core.model import BaseEntry, EntryType, SDFFile +from sdf_toolkit.io.annotate import PortDirection, YosysDesign, YosysModule + + +class DriverResolutionError(ValueError): + """Raised when a PORT sink cannot be mapped to exactly one driver.""" + + +class _Endpoint(NamedTuple): + """One pin attached to a net: a cell instance pin, or a top-level port.""" + + instance: str | None # None marks a top-level port + pin: str + cell_type: str | None # cell type for instance pins; None for top ports + top_direction: PortDirection | None # set only for top-level ports + + +class _Role: + """How an endpoint relates to its net.""" + + DRIVER = "driver" + SINK = "sink" + UNKNOWN = "unknown" + + +def _pin_roles(sdf: SDFFile) -> tuple[dict[str, set[str]], dict[str, set[str]]]: + """Return ``(outputs, inputs)`` pin-name sets per cell type from the SDF. + + IOPATH ``to_pin`` names are outputs; IOPATH ``from_pin`` names and PORT pins + are inputs. + + Parameters + ---------- + sdf : SDFFile + The SDF whose cells carry the IOPATH and PORT entries. + + Returns + ------- + tuple[dict[str, set[str]], dict[str, set[str]]] + Output pin names and input pin names, keyed by cell type. + """ + outputs: dict[str, set[str]] = {} + inputs: dict[str, set[str]] = {} + for cell_type, instances in sdf.cells.items(): + for entries in instances.values(): + for entry in entries.values(): + if entry.type == EntryType.IOPATH: + if entry.to_pin: + outputs.setdefault(cell_type, set()).add(entry.to_pin) + if entry.from_pin: + inputs.setdefault(cell_type, set()).add(entry.from_pin) + elif entry.type == EntryType.PORT and entry.from_pin: + inputs.setdefault(cell_type, set()).add(entry.from_pin) + return outputs, inputs + + +def _bit_endpoints(module: YosysModule) -> dict[int, list[_Endpoint]]: + """Map each net bit to the cell pins and top ports attached to it. + + Parameters + ---------- + module : YosysModule + The top-level module whose connectivity defines the nets. + + Returns + ------- + dict[int, list[_Endpoint]] + Endpoints keyed by net bit index. + """ + endpoints: dict[int, list[_Endpoint]] = {} + for inst, cell in module.cells.items(): + for pin, bits in cell.connections.items(): + for bit in bits: + if isinstance(bit, int): + endpoints.setdefault(bit, []).append( + _Endpoint(inst, pin, cell.cell_type, None) + ) + for port_name, port in module.ports.items(): + for bit in port.bits: + if isinstance(bit, int): + endpoints.setdefault(bit, []).append( + _Endpoint(None, port_name, None, port.direction) + ) + return endpoints + + +def _classify( + endpoint: _Endpoint, + outputs: dict[str, set[str]], + inputs: dict[str, set[str]], +) -> str: + """Classify an endpoint as a driver, a sink, or unknown.""" + if endpoint.instance is None: + # A top-level input port drives the internal net; an output port loads it. + if endpoint.top_direction == PortDirection.INPUT: + return _Role.DRIVER + return _Role.SINK + cell_type = endpoint.cell_type or "" + if endpoint.pin in outputs.get(cell_type, ()): + return _Role.DRIVER + if endpoint.pin in inputs.get(cell_type, ()): + return _Role.SINK + return _Role.UNKNOWN + + +def _source_path(endpoint: _Endpoint, divider: str) -> str: + """Format an endpoint as an SDF port path (``inst/pin`` or top ``port``).""" + if endpoint.instance is None: + return endpoint.pin + return f"{endpoint.instance}{divider}{endpoint.pin}" + + +def _resolve_driver( + sink: tuple[str, str], + bit: int, + endpoints: dict[int, list[_Endpoint]], + outputs: dict[str, set[str]], + inputs: dict[str, set[str]], + divider: str, +) -> str | None: + """Return the unique driver path for the net at *bit*, or None if ambiguous. + + A net is driven by exactly one source. The driver is the unique endpoint that + classifies as a driver; if none does (the driving cell is untimed), it is the + unique endpoint that is not a known sink. Zero or several candidates leave the + net unresolved. + + Parameters + ---------- + sink : tuple[str, str] + The ``(instance, pin)`` of the PORT load, excluded from the candidates. + bit : int + The net bit shared by the driver and the sink. + endpoints : dict[int, list[_Endpoint]] + All endpoints keyed by net bit. + outputs, inputs : dict[str, set[str]] + Output and input pin names per cell type. + divider : str + Hierarchy divider for formatting the driver path. + + Returns + ------- + str | None + The driver port path, or None when it is not unique. + """ + others = [ + endpoint + for endpoint in endpoints.get(bit, []) + if (endpoint.instance, endpoint.pin) != sink + ] + candidates = [ + endpoint + for endpoint in others + if _classify(endpoint, outputs, inputs) == _Role.DRIVER + ] + if not candidates: + candidates = [ + endpoint + for endpoint in others + if _classify(endpoint, outputs, inputs) == _Role.UNKNOWN + ] + if len(candidates) == 1: + return _source_path(candidates[0], divider) + return None + + +def port_to_interconnect( + sdf: SDFFile, + design: YosysDesign, + top_module: str | None = None, +) -> SDFFile: + """Return a copy of *sdf* with PORT delays rewritten as INTERCONNECT delays. + + Each ``(PORT pin delay)`` on a cell instance is replaced by an + ``(INTERCONNECT driver_path instance/pin delay)`` entry on the top-level cell, + where the driver is recovered from *design*. The original PORT entries are + removed. + + Parameters + ---------- + sdf : SDFFile + The SDF carrying PORT delays (typically from Libero ``EXPORTSDF``). + design : YosysDesign + The gate-level netlist parsed from Verilog via Yosys. + top_module : str | None + Top module name. Defaults to the SDF header ``DESIGN``. + + Returns + ------- + SDFFile + A new SDF with INTERCONNECT entries in place of PORT entries. + + Raises + ------ + DriverResolutionError + If the top module is missing, or any PORT sink does not resolve to + exactly one driver. The message lists every unresolved sink. + """ + top_module = top_module or sdf.header.design + if not top_module: + msg = "SDF header has no DESIGN; pass top_module explicitly" + raise DriverResolutionError(msg) + if top_module not in design.modules: + msg = ( + f"top module {top_module!r} not found in netlist " + f"(have {sorted(design.modules)})" + ) + raise DriverResolutionError(msg) + + module = design.modules[top_module] + divider = sdf.header.divider or "/" + outputs, inputs = _pin_roles(sdf) + endpoints = _bit_endpoints(module) + + result = copy.deepcopy(sdf) + interconnects: dict[str, BaseEntry] = {} + unresolved: list[str] = [] + + for instances in result.cells.values(): + for inst, entries in instances.items(): + cell = module.cells.get(inst) + port_keys = [ + key for key, entry in entries.items() if entry.type == EntryType.PORT + ] + for key in port_keys: + entry = entries.pop(key) + pin = entry.from_pin or "" + sink_path = f"{inst}{divider}{pin}" + if cell is None: + unresolved.append(f"{sink_path} (instance not in netlist)") + continue + bits = [b for b in cell.connections.get(pin, []) if isinstance(b, int)] + if len(bits) != 1: + unresolved.append( + f"{sink_path} (expected a scalar pin, found {len(bits)} bits)" + ) + continue + source = _resolve_driver( + (inst, pin), bits[0], endpoints, outputs, inputs, divider + ) + if source is None: + unresolved.append(f"{sink_path} (no unique driver on its net)") + continue + # Reuse the builder factory for the canonical entry + naming; + # carry over the PORT's absolute/incremental context so the entry + # emits in the same delay block. + interconnect = make_interconnect(source, sink_path, entry.delay_paths) + interconnect.is_absolute = entry.is_absolute + interconnect.is_incremental = entry.is_incremental + interconnects[interconnect.name] = interconnect + + if unresolved: + joined = "\n ".join(unresolved) + msg = f"could not resolve a unique driver for these PORT sinks:\n {joined}" + raise DriverResolutionError(msg) + + if interconnects: + top_cell = result.cells.setdefault(top_module, {}).setdefault(top_module, {}) + top_cell.update(interconnects) + + return result diff --git a/src/sdf_toolkit/transform/merge.py b/src/sdf_toolkit/transform/merge.py index 4796199..ec6ca2a 100644 --- a/src/sdf_toolkit/transform/merge.py +++ b/src/sdf_toolkit/transform/merge.py @@ -1,13 +1,12 @@ """Merge two or more SDF files into one.""" import copy -from enum import StrEnum -from sdf_toolkit.core.model import BaseEntry, SDFFile +from sdf_toolkit.core.model import BaseEntry, CaseInsensitiveStrEnum, SDFFile from sdf_toolkit.transform.normalize import normalize_delays -class ConflictStrategy(StrEnum): +class ConflictStrategy(CaseInsensitiveStrEnum): """Strategy for handling conflicting entries during merge.""" KEEP_FIRST = "keep-first" diff --git a/tests/data/golden/port_netlist.sdf b/tests/data/golden/port_netlist.sdf new file mode 100644 index 0000000..db1da4d --- /dev/null +++ b/tests/data/golden/port_netlist.sdf @@ -0,0 +1,38 @@ +(DELAYFILE + (SDFVERSION "3.0") + (DESIGN "port_top") + (DIVIDER /) + (TIMESCALE 1ns) + + (CELL + (CELLTYPE "AND2") + (INSTANCE u_and) + (DELAY + (ABSOLUTE + (IOPATH i1 z (0.5:0.5:0.5)) + (IOPATH i2 z (0.5:0.5:0.5)) + (PORT i1 (0.2:0.2:0.2)) + (PORT i2 (0.3:0.3:0.3)) + ) + ) + ) + (CELL + (CELLTYPE "BUF") + (INSTANCE u_buf) + (DELAY + (ABSOLUTE + (PORT a (0.1:0.1:0.1)) + ) + ) + ) + (CELL + (CELLTYPE "INV") + (INSTANCE u_inv) + (DELAY + (ABSOLUTE + (IOPATH i z (0.6:0.6:0.6)) + (PORT i (0.4:0.4:0.4)) + ) + ) + ) +) diff --git a/tests/data/port_netlist.sdf b/tests/data/port_netlist.sdf new file mode 100644 index 0000000..8cb9b41 --- /dev/null +++ b/tests/data/port_netlist.sdf @@ -0,0 +1,37 @@ +(DELAYFILE + (SDFVERSION "3.0") + (DESIGN "port_top") + (DIVIDER /) + (TIMESCALE 1ns) + (CELL + (CELLTYPE "BUF") + (INSTANCE u_buf) + (DELAY + (ABSOLUTE + (PORT a (0.1:0.1:0.1)) + ) + ) + ) + (CELL + (CELLTYPE "AND2") + (INSTANCE u_and) + (DELAY + (ABSOLUTE + (PORT i1 (0.2:0.2:0.2)) + (PORT i2 (0.3:0.3:0.3)) + (IOPATH i1 z (0.5:0.5:0.5)) + (IOPATH i2 z (0.5:0.5:0.5)) + ) + ) + ) + (CELL + (CELLTYPE "INV") + (INSTANCE u_inv) + (DELAY + (ABSOLUTE + (PORT i (0.4:0.4:0.4)) + (IOPATH i z (0.6:0.6:0.6)) + ) + ) + ) +) diff --git a/tests/data/port_netlist.v b/tests/data/port_netlist.v new file mode 100644 index 0000000..42c1a9e --- /dev/null +++ b/tests/data/port_netlist.v @@ -0,0 +1,26 @@ +// Fixture for PORT->INTERCONNECT conversion. +// +// An untimed buffer feeds an AND gate, which feeds an inverter. The buffer has +// no IOPATH in the companion SDF, so its driving pin must be found by +// elimination. Primitives are blackboxes so Yosys keeps the instances and their +// bit-level connectivity while still knowing the top-level port directions. + +(* blackbox *) +module BUF(input a, output y); +endmodule + +(* blackbox *) +module AND2(input i1, input i2, output z); +endmodule + +(* blackbox *) +module INV(input i, output z); +endmodule + +module port_top(input a, input b, output y); + wire x_buf; + wire x_and; + BUF u_buf (.a(a), .y(x_buf)); + AND2 u_and (.i1(x_buf), .i2(b), .z(x_and)); + INV u_inv (.i(x_and), .z(y)); +endmodule diff --git a/tests/test_interconnect.py b/tests/test_interconnect.py new file mode 100644 index 0000000..030fedb --- /dev/null +++ b/tests/test_interconnect.py @@ -0,0 +1,243 @@ +"""Tests for PORT to INTERCONNECT conversion.""" + +import shutil +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from sdf_toolkit.cli import app +from sdf_toolkit.core.builder import make_iopath, make_port +from sdf_toolkit.core.model import ( + DelayPaths, + EntryType, + Iopath, + Port, + SDFFile, + SDFHeader, + Values, +) +from sdf_toolkit.io.annotate import ( + PortDirection, + YosysCell, + YosysDesign, + YosysModule, + YosysPort, + parse_yosys_json, + run_yosys, +) +from sdf_toolkit.parser.parser import parse_sdf +from sdf_toolkit.transform.interconnect import ( + DriverResolutionError, + port_to_interconnect, +) + +DATA_DIR = (Path(__file__).parent / "data").resolve() +HAS_YOSYS = shutil.which("yosys") is not None + + +def _triple(value: float) -> DelayPaths: + """Return a nominal DelayPaths with a single min:typ:max value.""" + return DelayPaths(nominal=Values(value, value, value)) + + +def _port(pin: str, value: float) -> Port: + """Build an absolute PORT entry on *pin* via the builder factory.""" + entry = make_port(pin, _triple(value)) + entry.is_absolute = True + return entry + + +def _iopath(from_pin: str, to_pin: str, value: float) -> Iopath: + """Build an absolute IOPATH entry via the builder factory.""" + entry = make_iopath(from_pin, to_pin, _triple(value)) + entry.is_absolute = True + return entry + + +def _sample_sdf() -> SDFFile: + """SDF matching tests/data/port_netlist.v: buf -> and -> inv with PORTs. + + The buffer (u_buf) has no IOPATH, so its output pin is only discoverable by + elimination. + """ + return SDFFile( + header=SDFHeader(design="port_top", divider="/", timescale="1ns"), + cells={ + "BUF": {"u_buf": {"port_a": _port("a", 0.1)}}, + "AND2": { + "u_and": { + "port_i1": _port("i1", 0.2), + "port_i2": _port("i2", 0.3), + "iopath_i1_z": _iopath("i1", "z", 0.5), + "iopath_i2_z": _iopath("i2", "z", 0.5), + } + }, + "INV": { + "u_inv": { + "port_i": _port("i", 0.4), + "iopath_i_z": _iopath("i", "z", 0.6), + } + }, + }, + ) + + +def _sample_design() -> YosysDesign: + """Netlist for the sample SDF, mirroring tests/data/port_netlist.v. + + Bit assignment: a=2, b=3, x_buf=4, x_and=5, y=6. + """ + return YosysDesign( + modules={ + "port_top": YosysModule( + name="port_top", + ports={ + "a": YosysPort("a", PortDirection.INPUT, [2]), + "b": YosysPort("b", PortDirection.INPUT, [3]), + "y": YosysPort("y", PortDirection.OUTPUT, [6]), + }, + cells={ + "u_buf": YosysCell("u_buf", "BUF", {"a": [2], "y": [4]}), + "u_and": YosysCell( + "u_and", "AND2", {"i1": [4], "i2": [3], "z": [5]} + ), + "u_inv": YosysCell("u_inv", "INV", {"i": [5], "z": [6]}), + }, + ) + } + ) + + +def _interconnects(sdf: SDFFile) -> dict[str, str]: + """Return {sink_path: source_path} for every INTERCONNECT in *sdf*.""" + pairs: dict[str, str] = {} + for instances in sdf.cells.values(): + for entries in instances.values(): + for entry in entries.values(): + if entry.type == EntryType.INTERCONNECT: + pairs[entry.to_pin] = entry.from_pin + return pairs + + +class TestPortToInterconnect: + """Unit tests using a hand-built netlist (no Yosys required).""" + + def test_drivers_resolved(self) -> None: + result = port_to_interconnect(_sample_sdf(), _sample_design()) + assert _interconnects(result) == { + "u_buf/a": "a", # top input port drives the buffer + "u_and/i1": "u_buf/y", # untimed buffer output, found by elimination + "u_and/i2": "b", # top input port + "u_inv/i": "u_and/z", # AND2 output identified by its IOPATH + } + + def test_port_entries_removed(self) -> None: + result = port_to_interconnect(_sample_sdf(), _sample_design()) + for instances in result.cells.values(): + for entries in instances.values(): + for entry in entries.values(): + assert entry.type != EntryType.PORT + + def test_interconnects_placed_on_top_cell(self) -> None: + result = port_to_interconnect(_sample_sdf(), _sample_design()) + top_cell = result.cells["port_top"]["port_top"] + assert len(top_cell) == 4 + assert all(e.type == EntryType.INTERCONNECT for e in top_cell.values()) + + def test_delay_is_carried_over(self) -> None: + result = port_to_interconnect(_sample_sdf(), _sample_design()) + by_sink = {e.to_pin: e for e in result.cells["port_top"]["port_top"].values()} + assert by_sink["u_buf/a"].delay_paths.nominal.max == 0.1 + assert by_sink["u_inv/i"].delay_paths.nominal.max == 0.4 + + def test_input_sdf_not_mutated(self) -> None: + sdf = _sample_sdf() + port_to_interconnect(sdf, _sample_design()) + # The original still has its PORT entries. + assert sdf.cells["BUF"]["u_buf"]["port_a"].type == EntryType.PORT + + def test_default_top_module_from_header(self) -> None: + # No explicit top_module: falls back to header DESIGN ("port_top"). + result = port_to_interconnect(_sample_sdf(), _sample_design()) + assert "port_top" in result.cells + + def test_missing_top_module_raises(self) -> None: + sdf = _sample_sdf() + sdf.header.design = None + with pytest.raises(DriverResolutionError, match="no DESIGN"): + port_to_interconnect(sdf, _sample_design()) + + def test_unknown_top_module_raises(self) -> None: + with pytest.raises(DriverResolutionError, match="not found in netlist"): + port_to_interconnect(_sample_sdf(), _sample_design(), top_module="nope") + + def test_instance_absent_from_netlist_raises(self) -> None: + design = _sample_design() + del design.modules["port_top"].cells["u_inv"] + with pytest.raises(DriverResolutionError, match="u_inv/i"): + port_to_interconnect(_sample_sdf(), design) + + def test_ambiguous_driver_raises(self) -> None: + # Two output pins on the same net leave the driver ambiguous. + design = _sample_design() + design.modules["port_top"].cells["u_extra"] = YosysCell( + "u_extra", + "AND2", + {"z": [2]}, # a second driver on net 'a' (bit 2) + ) + with pytest.raises(DriverResolutionError, match="no unique driver"): + port_to_interconnect(_sample_sdf(), design) + + +@pytest.mark.skipif(not HAS_YOSYS, reason="Yosys not installed") +class TestPortToInterconnectWithYosys: + """Integration tests parsing the fixture netlist with Yosys.""" + + def test_fixture_round_trip(self) -> None: + sdf = parse_sdf((DATA_DIR / "port_netlist.sdf").read_text()) + design = parse_yosys_json(run_yosys(DATA_DIR / "port_netlist.v")) + result = port_to_interconnect(sdf, design) + assert _interconnects(result) == { + "u_buf/a": "a", + "u_and/i1": "u_buf/y", + "u_and/i2": "b", + "u_inv/i": "u_and/z", + } + + +@pytest.mark.skipif(not HAS_YOSYS, reason="Yosys not installed") +class TestPortToInterconnectCli: + """Test the CLI port-to-interconnect command.""" + + def test_stdout_output(self) -> None: + runner = CliRunner() + result = runner.invoke( + app, + [ + "port-to-interconnect", + str(DATA_DIR / "port_netlist.sdf"), + str(DATA_DIR / "port_netlist.v"), + ], + ) + assert result.exit_code == 0 + assert "(INTERCONNECT a u_buf/a" in result.stdout + assert "(INTERCONNECT u_buf/y u_and/i1" in result.stdout + assert "(PORT " not in result.stdout + + def test_file_output(self, tmp_path: Path) -> None: + output = tmp_path / "out.sdf" + runner = CliRunner() + result = runner.invoke( + app, + [ + "port-to-interconnect", + str(DATA_DIR / "port_netlist.sdf"), + str(DATA_DIR / "port_netlist.v"), + "-o", + str(output), + ], + ) + assert result.exit_code == 0 + assert output.exists() + assert "INTERCONNECT" in output.read_text() diff --git a/tests/test_typer_cli.py b/tests/test_typer_cli.py index a1c2f8d..0dc13e8 100644 --- a/tests/test_typer_cli.py +++ b/tests/test_typer_cli.py @@ -281,6 +281,19 @@ def test_query_entry_type_filter(self) -> None: data = json.loads(result.output) assert "cells" in data + def test_query_entry_type_uppercase(self) -> None: + # The natural SDF spelling is upper case; it must not crash. + result = runner.invoke(app, ["query", SPEC_EXAMPLE1, "--entry-type", "IOPATH"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "cells" in data + + def test_query_entry_type_invalid_is_clean_error(self) -> None: + result = runner.invoke(app, ["query", SPEC_EXAMPLE1, "--entry-type", "bogus"]) + assert result.exit_code != 0 + # A clean parameter error, not a raw traceback. + assert "Traceback" not in result.output + class TestDiffCmd: def test_diff_identical(self) -> None: