Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 90 additions & 5 deletions src/sdf_toolkit/core/pathgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@

import networkx as nx

from sdf_toolkit.core.model import DelayField, DelayFieldLike, DelayMetric, DelayMetricLike, DelayPaths, EntryType, SDFFile
from sdf_toolkit.core.model import (
BaseEntry,
DelayField,
DelayFieldLike,
DelayMetric,
DelayMetricLike,
DelayPaths,
EntryType,
SDFFile,
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -152,30 +161,48 @@ class TimingGraph:
----------
sdf : SDFFile
The parsed SDF file to build the graph from.
traverse_registers : bool, optional
When True, every SETUP/SETUPHOLD timing check additionally adds a
data-pin to clock-pin edge whose delay is the check's setup time.
Sequential cells then become traversable: a path can continue
from a register's data input through its clock pin and on through
the cell's clock-to-output IOPATH. By default True, which makes
registers traversable so timing paths may cross sequential cells.
"""

def __init__(self, sdf: SDFFile) -> None:
def __init__(self, sdf: SDFFile, traverse_registers: bool = True) -> None:
self._graph: nx.MultiDiGraph = nx.MultiDiGraph()
self._build(sdf)
self._build(sdf, traverse_registers)

def _build(self, sdf: SDFFile) -> None:
def _build(self, sdf: SDFFile, traverse_registers: bool) -> None:
"""Populate the graph from SDF cells.

For each cell in the SDF file, IOPATH and INTERCONNECT entries
are converted to directed edges. IOPATH pin names are qualified
with the instance hierarchy path, while INTERCONNECT pin names
are used as-is (they are already fully qualified).
are used as-is (they are already fully qualified). With
``traverse_registers``, SETUP/SETUPHOLD checks are converted to
data-to-clock edges as well.

Parameters
----------
sdf : SDFFile
The parsed SDF file.
traverse_registers : bool
Whether to add data-to-clock edges for setup-style checks.
"""
divider = sdf.header.divider or "/"

for cell_type, instances in sdf.cells.items():
for instance, entries in instances.items():
for _entry_name, entry in entries.items():
if traverse_registers and entry.type in (
EntryType.SETUP,
EntryType.SETUPHOLD,
):
self._add_register_edge(entry, cell_type, instance, divider)
continue

if entry.type not in (EntryType.IOPATH, EntryType.INTERCONNECT):
continue

Expand All @@ -201,6 +228,64 @@ def _build(self, sdf: SDFFile) -> None:
instance=instance,
)

def _add_register_edge(
self,
entry: "BaseEntry",
cell_type: str,
instance: str,
divider: str,
) -> None:
"""Add a data-to-clock edge for a setup-style timing check.

Timing checks are clock-relative: ``from_pin`` is the clock pin
and ``to_pin`` the data pin. The edge runs data to clock and its
delay is the setup time, the physically meaningful forward cost
of crossing the register boundary (data must be stable that long
before the clock edge; propagation then continues through the
cell's clock-to-output IOPATH). HOLD checks add no edge: hold is
a minimum-arrival constraint, not a propagation cost, and its
typically negative values would corrupt path delay sums.

SDF allows several setup checks per pin pair (edge or condition
variants); each becomes its own parallel edge, consistent with
how conditional IOPATH variants are kept as parallel edges.

Parameters
----------
entry : BaseEntry
A SETUP or SETUPHOLD timing check entry.
cell_type : str
The cell type from the SDF file.
instance : str
The instance name from the SDF file.
divider : str
The hierarchy divider character.
"""
if entry.from_pin is None or entry.to_pin is None:
return
if entry.delay_paths is None:
return

# SETUP stores its value in `nominal`; SETUPHOLD splits into
# `setup`/`hold`, of which only the setup half is a forward cost.
if entry.type == EntryType.SETUP:
setup_values = entry.delay_paths.nominal
else:
setup_values = entry.delay_paths.setup
if setup_values is None:
return

data_pin = _qualify_pin(instance, entry.to_pin, divider)
clock_pin = _qualify_pin(instance, entry.from_pin, divider)
self._graph.add_edge(
data_pin,
clock_pin,
delay=DelayPaths(nominal=setup_values),
entry_type=entry.type,
cell_type=cell_type,
instance=instance,
)

@property
def graph(self) -> nx.MultiDiGraph:
"""Expose the underlying NetworkX MultiDiGraph for advanced analysis.
Expand Down
125 changes: 117 additions & 8 deletions tests/test_pathgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def test_setup_hold_entries_skipped(self) -> None:
.add_iopath("D", "Q", {"nominal": {"min": 1.0, "avg": 1.0, "max": 1.0}})
.build()
)
graph = TimingGraph(sdf)
graph = TimingGraph(sdf, traverse_registers=False)
# Only the IOPATH should create edges; SETUP/HOLD are skipped
assert len(graph.edges()) == 1

Expand Down Expand Up @@ -218,7 +218,7 @@ def test_empty_sdf(self) -> None:
class TestParallelEdges:
"""Bug #1: find_paths overcounts when parallel edges exist."""

@pytest.fixture()
@pytest.fixture
def parallel_graph(self) -> TimingGraph:
"""Graph with two parallel IOPATH edges from A to Y in one cell."""
sdf = (
Expand Down Expand Up @@ -246,14 +246,13 @@ def test_find_paths_count_with_parallel_edges(
paths = parallel_graph.find_paths("b0/A", "b0/Y")
assert len(paths) == 2

def test_find_paths_no_duplicate_delays(
self, parallel_graph: TimingGraph
) -> None:
def test_find_paths_no_duplicate_delays(self, parallel_graph: TimingGraph) -> None:
"""Each parallel edge path should have a distinct delay."""
paths = parallel_graph.find_paths("b0/A", "b0/Y")
delays = [parallel_graph.compose_delay(p) for p in paths]
scalars = sorted(
d.get_scalar("slow", "max") for d in delays # type: ignore[type-var]
d.get_scalar("slow", "max")
for d in delays # type: ignore[type-var]
)
assert scalars == [3.0, 6.0]

Expand Down Expand Up @@ -285,7 +284,7 @@ def test_batch_endpoint_path_count_with_parallel_edges(
class TestParallelEdgesMultiHop:
"""Parallel edges on multi-hop paths."""

@pytest.fixture()
@pytest.fixture
def multi_hop_parallel_graph(self) -> TimingGraph:
"""a/Y --(2 edges)--> b/A -> b/Y with 1 edge."""
sdf = (
Expand Down Expand Up @@ -314,7 +313,7 @@ def test_multi_hop_parallel_count(
class TestNoneScalarSorting:
"""Bug #3: None scalars should sort last in rank_paths, not first."""

@pytest.fixture()
@pytest.fixture
def mixed_field_graph(self) -> TimingGraph:
"""Graph where one path yields a scalar and another yields None."""
sdf = (
Expand Down Expand Up @@ -448,3 +447,113 @@ def test_source_equals_sink_nonexistent(self, spec1_graph: TimingGraph) -> None:
"""find_paths with source==sink on nonexistent node should raise."""
with pytest.raises(nx.NodeNotFound):
spec1_graph.find_paths("NONEXISTENT", "NONEXISTENT")


def _register_sdf() -> SDFFile:
"""One flop between two top-level pins: din -> ff/D, ff/Q -> dout."""
return (
SDFBuilder()
.set_header(timescale="1ns")
.add_cell("DFF", "ff")
.add_iopath("CLK", "Q", {"nominal": {"min": 0.3, "max": 0.3}})
.add_interconnect("din", "ff/D", {"nominal": {"min": 0.1, "max": 0.1}})
.add_interconnect("ff/Q", "dout", {"nominal": {"min": 0.2, "max": 0.2}})
.add_setup("CLK", "D", {"nominal": {"min": 0.05, "max": 0.05}})
.add_hold("CLK", "D", {"nominal": {"min": -0.02, "max": -0.02}})
.build()
)


class TestTraverseRegisters:
"""Optional data-to-clock edges for setup-style timing checks."""

def test_default_graph_stops_at_register(self) -> None:
"""Without the option, registers end timing paths."""
g = TimingGraph(_register_sdf(), traverse_registers=False)
assert "dout" not in nx.descendants(g.graph, "din")
assert not g.graph.has_edge("ff/D", "ff/CLK")

def test_register_becomes_traversable(self) -> None:
"""The data-to-clock edge lets paths continue through the flop."""
g = TimingGraph(_register_sdf(), traverse_registers=True)
assert "dout" in nx.descendants(g.graph, "din")

def test_edge_carries_setup_time(self) -> None:
"""The pass-through edge's delay is the setup time."""
g = TimingGraph(_register_sdf(), traverse_registers=True)
arcs = g.graph.get_edge_data("ff/D", "ff/CLK")
assert len(arcs) == 1
(arc,) = arcs.values()
assert arc["entry_type"] == EntryType.SETUP
assert arc["delay"].get_scalar("nominal", "max") == 0.05

def test_composed_path_includes_setup_and_clk_to_q(self) -> None:
"""din -> dout composes interconnects, setup, and CLK->Q IOPATH."""
g = TimingGraph(_register_sdf(), traverse_registers=True)
paths = g.find_paths("din", "dout")
assert len(paths) == 1
delay = g.compose_delay(paths[0])
# 0.1 (din->D) + 0.05 (setup) + 0.3 (CLK->Q) + 0.2 (Q->dout)
assert delay.get_scalar("nominal", "max") == pytest.approx(0.65)

def test_hold_check_adds_no_edge(self) -> None:
"""HOLD is a minimum-arrival constraint, not a propagation cost."""
sdf = (
SDFBuilder()
.set_header(timescale="1ns")
.add_cell("DFF", "ff")
.add_hold("CLK", "D", {"nominal": {"min": -0.02, "max": -0.02}})
.build()
)
g = TimingGraph(sdf, traverse_registers=True)
assert not g.graph.has_edge("ff/D", "ff/CLK")

def test_setuphold_uses_setup_half(self) -> None:
"""SETUPHOLD contributes its setup value, never the hold value."""
sdf = (
SDFBuilder()
.set_header(timescale="1ns")
.add_cell("DFF", "ff")
.add_setuphold(
"CLK",
"D",
{
"setup": {"min": 0.07, "max": 0.07},
"hold": {"min": -0.03, "max": -0.03},
},
)
.build()
)
g = TimingGraph(sdf, traverse_registers=True)
arcs = g.graph.get_edge_data("ff/D", "ff/CLK")
assert len(arcs) == 1
(arc,) = arcs.values()
assert arc["entry_type"] == EntryType.SETUPHOLD
assert arc["delay"].get_scalar("nominal", "max") == 0.07
assert arc["delay"].get_scalar("hold", "max") is None

def test_parsed_setup_variants_become_parallel_edges(self) -> None:
"""Edge-specific SETUP variants each add their own parallel edge."""
sdf_text = """(DELAYFILE
(SDFVERSION "3.0")
(DESIGN "top")
(DIVIDER /)
(TIMESCALE 1ns)
(CELL
(CELLTYPE "DFF")
(INSTANCE ff)
(TIMINGCHECK
(SETUP (posedge D) (posedge CLK) (0.11::0.11))
(SETUP (negedge D) (posedge CLK) (0.13::0.13))
)
)
)"""
from sdf_toolkit.io import parse

g = TimingGraph(parse(sdf_text), traverse_registers=True)
arcs = g.graph.get_edge_data("ff/D", "ff/CLK")
assert len(arcs) == 2
scalars = sorted(
arc["delay"].get_scalar("nominal", "max") for arc in arcs.values()
)
assert scalars == [0.11, 0.13]