From c2394d4af48f0b969c2952cd57ff379c35208648 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 10 Jun 2026 09:12:36 +0100 Subject: [PATCH] feat(parser): support PERIOD timing checks OpenSTA emits (PERIOD port value) timing checks for cells whose liberty defines a minimum clock period (e.g. gf180mcu dffq), and the grammar rejected them. Add the PERIOD check across the stack, mirroring WIDTH: - grammar: period_check rule, included in t_check - model: EntryType.PERIOD and the Period dataclass - transformer: period_check (single port, nominal value) - builder: CellBuilder.add_period - SDF writer template: single-port rendering like width - annotate: $period(...) specify rendering --- src/sdf_toolkit/core/__init__.py | 2 + src/sdf_toolkit/core/builder.py | 22 +++++++++ src/sdf_toolkit/core/model.py | 20 +++++++- src/sdf_toolkit/io/annotate.py | 5 ++ src/sdf_toolkit/io/templates/timingcheck.j2 | 2 +- src/sdf_toolkit/parser/sdf.lark | 4 +- src/sdf_toolkit/parser/transformers.py | 7 +++ tests/test_annotate.py | 27 +++++++++++ tests/test_new_features.py | 3 +- tests/test_transformers.py | 54 +++++++++++++++++++++ 10 files changed, 141 insertions(+), 5 deletions(-) diff --git a/src/sdf_toolkit/core/__init__.py b/src/sdf_toolkit/core/__init__.py index b087bbb..0575cad 100644 --- a/src/sdf_toolkit/core/__init__.py +++ b/src/sdf_toolkit/core/__init__.py @@ -24,6 +24,7 @@ Interconnect, Iopath, PathConstraint, + Period, Port, PortSpec, Recovery, @@ -55,6 +56,7 @@ "Interconnect", "Iopath", "PathConstraint", + "Period", "Port", "PortSpec", "Recovery", diff --git a/src/sdf_toolkit/core/builder.py b/src/sdf_toolkit/core/builder.py index 213f312..9485ee2 100644 --- a/src/sdf_toolkit/core/builder.py +++ b/src/sdf_toolkit/core/builder.py @@ -13,6 +13,7 @@ Interconnect, Iopath, PathConstraint, + Period, Port, Recovery, Removal, @@ -639,6 +640,27 @@ def add_width( cond_equation=cond_equation, ) + def add_period( + self, + pin: str, + delays: DelaysInput, + *, + pin_edge: EdgeType | None = None, + is_cond: bool = False, + cond_equation: str | None = None, + ) -> "CellBuilder": + """Add a period timing check entry.""" + return self._add_timing_check( + Period, + pin, + pin, + delays, + from_pin_edge=pin_edge, + to_pin_edge=pin_edge, + is_cond=is_cond, + cond_equation=cond_equation, + ) + def add_path_constraint( self, from_pin: str, diff --git a/src/sdf_toolkit/core/model.py b/src/sdf_toolkit/core/model.py index d77a127..288fbb5 100644 --- a/src/sdf_toolkit/core/model.py +++ b/src/sdf_toolkit/core/model.py @@ -19,6 +19,7 @@ class EntryType(StrEnum): REMOVAL = "removal" RECOVERY = "recovery" WIDTH = "width" + PERIOD = "period" SETUPHOLD = "setuphold" PATHCONSTRAINT = "pathconstraint" @@ -50,7 +51,9 @@ class DelayMetric(StrEnum): MAX = "max" -DelayFieldLike = DelayField | Literal["nominal", "fast", "slow", "setup", "hold", "rise", "fall"] +DelayFieldLike = ( + DelayField | Literal["nominal", "fast", "slow", "setup", "hold", "rise", "fall"] +) DelayMetricLike = DelayMetric | Literal["min", "avg", "max"] @@ -314,7 +317,11 @@ class DelayPaths: rise: Values | None = None fall: Values | None = None - def get_scalar(self, field: DelayFieldLike = DelayField.SLOW, metric: DelayMetricLike = DelayMetric.MAX) -> float | None: + def get_scalar( + self, + field: DelayFieldLike = DelayField.SLOW, + metric: DelayMetricLike = DelayMetric.MAX, + ) -> float | None: """Extract a single float from a named field and metric. Parameters @@ -559,6 +566,15 @@ def __post_init__(self) -> None: self.type = EntryType.WIDTH +@dataclass +class Period(TimingCheck): + """Period timing check entry.""" + + def __post_init__(self) -> None: + """Set entry type to PERIOD.""" + self.type = EntryType.PERIOD + + @dataclass class SetupHold(TimingCheck): """SetupHold combined timing check entry.""" diff --git a/src/sdf_toolkit/io/annotate.py b/src/sdf_toolkit/io/annotate.py index 4afd5a4..4bdcb4f 100644 --- a/src/sdf_toolkit/io/annotate.py +++ b/src/sdf_toolkit/io/annotate.py @@ -36,6 +36,7 @@ class SpecifyKind(StrEnum): HOLD = "hold" SETUPHOLD = "setuphold" WIDTH = "width" + PERIOD = "period" RECOVERY = "recovery" REMOVAL = "removal" @@ -368,6 +369,7 @@ def entries_to_specify( EntryType.SETUP: SpecifyKind.SETUP, EntryType.HOLD: SpecifyKind.HOLD, EntryType.WIDTH: SpecifyKind.WIDTH, + EntryType.PERIOD: SpecifyKind.PERIOD, EntryType.RECOVERY: SpecifyKind.RECOVERY, EntryType.REMOVAL: SpecifyKind.REMOVAL, } @@ -473,6 +475,9 @@ def _format_specify_entry(entry: SpecifyEntry) -> str: if entry.kind == SpecifyKind.WIDTH: return f"{indent}$width({data_str}, {entry.rise_delay});" + if entry.kind == SpecifyKind.PERIOD: + return f"{indent}$period({data_str}, {entry.rise_delay});" + # hold, recovery, removal all share: $kind(ref, data, limit) if entry.kind in (SpecifyKind.HOLD, SpecifyKind.RECOVERY, SpecifyKind.REMOVAL): return f"{indent}${entry.kind}({ref_str}, {data_str}, {entry.rise_delay});" diff --git a/src/sdf_toolkit/io/templates/timingcheck.j2 b/src/sdf_toolkit/io/templates/timingcheck.j2 index 8e5b104..1d4c78d 100644 --- a/src/sdf_toolkit/io/templates/timingcheck.j2 +++ b/src/sdf_toolkit/io/templates/timingcheck.j2 @@ -10,7 +10,7 @@ {%- set input_str = "(COND " ~ entry.cond_equation ~ " " ~ input_str ~ ")" -%} {%- endif %} - {%- if entry.name.startswith('width') %} + {%- if entry.name.startswith('width') or entry.name.startswith('period') %} {%- set output_str = "" -%} {%- endif %} diff --git a/src/sdf_toolkit/parser/sdf.lark b/src/sdf_toolkit/parser/sdf.lark index 3bd2610..6b87e08 100644 --- a/src/sdf_toolkit/parser/sdf.lark +++ b/src/sdf_toolkit/parser/sdf.lark @@ -52,7 +52,7 @@ timing_check: "(" "TIMINGCHECK" timing_check_list ")" timing_check_list: t_check+ -t_check: removal_check | recovery_check | hold_check | setup_check | width_check | setuphold_check +t_check: removal_check | recovery_check | hold_check | setup_check | width_check | period_check | setuphold_check removal_check: "(" "REMOVAL" timing_port timing_port rvalue ")" @@ -64,6 +64,8 @@ setup_check: "(" "SETUP" timing_port timing_port rvalue ")" width_check: "(" "WIDTH" timing_port rvalue ")" +period_check: "(" "PERIOD" timing_port rvalue ")" + setuphold_check: "(" "SETUPHOLD" timing_port timing_port rvalue rvalue ")" timing_port: port_spec | "(" "COND" equation port_spec ")" diff --git a/src/sdf_toolkit/parser/transformers.py b/src/sdf_toolkit/parser/transformers.py index 0b47407..3cc738b 100644 --- a/src/sdf_toolkit/parser/transformers.py +++ b/src/sdf_toolkit/parser/transformers.py @@ -13,6 +13,7 @@ Interconnect, Iopath, PathConstraint, + Period, Port, PortSpec, Recovery, @@ -395,6 +396,12 @@ def width_check(self, port: TimingPortSpec, values: Values) -> Width: paths = DelayPaths(nominal=values) return self._make_timing_check(Width, port, port, paths) + @v_args(inline=True) + def period_check(self, port: TimingPortSpec, values: Values) -> Period: + """Process period timing check.""" + paths = DelayPaths(nominal=values) + return self._make_timing_check(Period, port, port, paths) + @v_args(inline=True) def setuphold_check( self, diff --git a/tests/test_annotate.py b/tests/test_annotate.py index 6ca27f3..8b02ff5 100644 --- a/tests/test_annotate.py +++ b/tests/test_annotate.py @@ -15,6 +15,7 @@ Hold, Interconnect, Iopath, + Period, Recovery, SDFFile, SDFHeader, @@ -414,6 +415,21 @@ def test_width(self) -> None: assert se.kind == "width" assert se.from_edge == "posedge" + def test_period(self) -> None: + entries = [ + Period( + name="period_CLK_CLK", + from_pin="CLK", + to_pin="CLK", + delay_paths=DelayPaths(nominal=Values(1.058, 1.058, 1.058)), + is_timing_check=True, + ), + ] + result = entries_to_specify(entries) + se = result[0] + assert se.kind == "period" + assert se.from_pin == "CLK" + def test_recovery(self) -> None: entries = [ Recovery( @@ -546,6 +562,17 @@ def test_width_check(self) -> None: block = render_specify_block(entries) assert "$width(posedge CP, 1:1:1);" in block + def test_period_check(self) -> None: + entries = [ + SpecifyEntry( + kind="period", + from_pin="CLK", + rise_delay="1.058:1.058:1.058", + ), + ] + block = render_specify_block(entries) + assert "$period(CLK, 1.058:1.058:1.058);" in block + def test_recovery_check(self) -> None: entries = [ SpecifyEntry( diff --git a/tests/test_new_features.py b/tests/test_new_features.py index ca419c3..0c57ad7 100644 --- a/tests/test_new_features.py +++ b/tests/test_new_features.py @@ -134,9 +134,10 @@ def test_all_entry_types(self): .add_recovery("RST", "CLK", delays) .add_setuphold("D", "CLK", delays) .add_width("CLK", delays) + .add_period("CLK", delays) ) sdf = builder.build() - assert len(sdf.cells["TOP"]["top0"]) == 10 + assert len(sdf.cells["TOP"]["top0"]) == 11 class TestValidate: diff --git a/tests/test_transformers.py b/tests/test_transformers.py index 34f79b9..0bb23a6 100644 --- a/tests/test_transformers.py +++ b/tests/test_transformers.py @@ -169,3 +169,57 @@ def test_invalid_port_spec_raises(self): Token("ID", "CLK"), Token("ID", "extra"), ) + + +class TestPeriodCheck: + PERIOD_SDF = """(DELAYFILE + (SDFVERSION "3.0") + (DESIGN "top") + (DIVIDER /) + (TIMESCALE 1.0 ns) + (CELL + (CELLTYPE "dff") + (INSTANCE ff0) + (TIMINGCHECK + (SETUP (posedge D) (posedge CLK) (0.118::0.118)) + (WIDTH (posedge CLK) (0.495::0.495)) + (PERIOD CLK (1.058::1.058)) + (PERIOD (posedge CLK2) (2.0::2.5)) + ) + ) +)""" + + def test_period_check_parsed(self): + """OpenSTA-style PERIOD checks parse into Period entries.""" + result = parse_sdf(self.PERIOD_SDF) + entries = result.cells["dff"]["ff0"] + periods = [e for e in entries.values() if e.type == EntryType.PERIOD] + assert len(periods) == 2 + + plain = entries["period_CLK_CLK"] + assert plain.is_timing_check + assert plain.from_pin == "CLK" + assert plain.to_pin == "CLK" + assert plain.delay_paths.nominal.min == 1.058 + assert plain.delay_paths.nominal.max == 1.058 + + def test_period_check_edge_qualified(self): + """Edge-qualified PERIOD ports keep their edge.""" + result = parse_sdf(self.PERIOD_SDF) + edged = result.cells["dff"]["ff0"]["period_CLK2_CLK2"] + assert edged.from_pin_edge is not None + assert edged.delay_paths.nominal.min == 2.0 + assert edged.delay_paths.nominal.max == 2.5 + + def test_period_check_round_trip(self): + """Emitting a parsed file reproduces the PERIOD check shape.""" + from sdf_toolkit.io import emit + + result = parse_sdf(self.PERIOD_SDF) + text = emit(result, timescale="1.0 ns") + assert "PERIOD" in text + # Re-parse the emitted text to prove the writer output is valid. + reparsed = parse_sdf(text) + entries = reparsed.cells["dff"]["ff0"] + periods = [e for e in entries.values() if e.type == EntryType.PERIOD] + assert len(periods) == 2