Skip to content
VLSI Mentor

Python Part II · Module 35 · Industry VLSI Automation Projects

An STA Timing-Report Analyzer

A timing report is the first input in this module that cannot be parsed one line at a time. The number you want — a slack — sits on a line that does not say which endpoint produced it, which corner it belongs to, or which clock group it is in; all of that was printed several lines earlier and has to be carried forward. Getting the parser right is the easy half. The hard half is the arithmetic on top of it, where one plausible mistake turns a failing corner into a passing one.

Advanced26 min readPythonPart IIAutomationSTAParsing

Part II · Module 35 · Chapter 35.2 · An STA Timing-Report Analyzer

1. What the Report Looks Like

No static-timing tool is installed on this machine, so every report in this chapter is a fixture written to the shape report_timing produces — the same standing convention Chapter 23.1 Section 1 set for the whole track. Nothing here is presented as tool output.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
REPORT = """\
****************************************
Report : timing
Design : axi_fifo
Corner : ss_0p81v_125c
****************************************

  Startpoint: wr_ptr_reg[3]
  Endpoint: mem_reg_12_
  Path Group: clk_core
  Path Type: max

  Point                                    Incr      Path
  ------------------------------------------------------------
  clock clk_core (rise edge)               0.000     0.000
  wr_ptr_reg[3]/CK (DFFQX1)                0.000     0.000 r
  mem_reg_12_/D (DFFQX1)                   0.412     1.873 f
  data arrival time                                  1.873

  clock clk_core (rise edge)               2.000     2.000
  library setup time                      -0.085     1.915
  data required time                                 1.915
  ------------------------------------------------------------
  slack (MET)                                        0.042
"""

print(REPORT, end="")
print()
lines = REPORT.splitlines()
print("lines in one path block            :", len(lines))
print("lines carrying the endpoint name   :", 1)
print("lines carrying the slack           :", 1)
print("lines between them                 :",
      next(i for i, l in enumerate(lines) if "slack (" in l)
      - next(i for i, l in enumerate(lines) if "Endpoint:" in l))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
****************************************
Report : timing
Design : axi_fifo
Corner : ss_0p81v_125c
****************************************

  Startpoint: wr_ptr_reg[3]
  Endpoint: mem_reg_12_
  Path Group: clk_core
  Path Type: max

  Point                                    Incr      Path
  ------------------------------------------------------------
  clock clk_core (rise edge)               0.000     0.000
  wr_ptr_reg[3]/CK (DFFQX1)                0.000     0.000 r
  mem_reg_12_/D (DFFQX1)                   0.412     1.873 f
  data arrival time                                  1.873

  clock clk_core (rise edge)               2.000     2.000
  library setup time                      -0.085     1.915
  data required time                                 1.915
  ------------------------------------------------------------
  slack (MET)                                        0.042

lines in one path block            : 23
lines carrying the endpoint name   : 1
lines carrying the slack           : 1
lines between them                 : 15

Fifteen lines separate the endpoint from its slack. That single fact is why this chapter is different from Chapter 35.1, where every failure was self-contained on one line — and the gap is not a fixed number either, since it grows with the length of the path being reported.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
analyze(reports) -> {corner: Timing(wns, tns, endpoints)}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass


@dataclass(frozen=True)
class Timing:
    """One corner's summary. Frozen — a signed-off number must not move."""
    wns: float
    tns: float
    endpoints: tuple
    paths: int

    @property
    def violated(self):
        return self.wns < 0


DECISIONS = [
    ("takes `reports`, not paths", "the pure layer — 34.3"),
    ("keyed by corner", "a corner is the unit of sign-off — 29.6"),
    ("wns AND tns", "one says how bad, the other says how much"),
    ("endpoints, plural", "a name to fix, not just a number"),
    ("a frozen dataclass", "a signed-off value must not be mutated"),
]

print(f"{'decision in the signature':<30} why")
for what, why in DECISIONS:
    print(f"{what:<30} {why}")

print()
t = Timing(wns=-0.173, tns=-0.234,
           endpoints=("out_data_reg_7_", "ctrl_reg_2_"), paths=4)
print("an example:", t)
print()
print("wns answers      :", "how bad is the worst path")
print("tns answers      :", "how much work is left in total")
print("endpoints answer :", "where to start")
print("violated         :", t.violated)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
decision in the signature      why
takes `reports`, not paths     the pure layer — 34.3
keyed by corner                a corner is the unit of sign-off — 29.6
wns AND tns                    one says how bad, the other says how much
endpoints, plural              a name to fix, not just a number
a frozen dataclass             a signed-off value must not be mutated

an example: Timing(wns=-0.173, tns=-0.234, endpoints=('out_data_reg_7_', 'ctrl_reg_2_'), paths=4)

wns answers      : how bad is the worst path
tns answers      : how much work is left in total
endpoints answer : where to start
violated         : True

wns and tns are both required because they answer different questions. A corner with wns = -0.173 and tns = -0.234 has essentially two bad paths; a corner with the same wns and tns = -41.6 has hundreds, and the second needs a different kind of attention. Reporting only one of them is Chapter 29.2's single-threshold problem.

3. The First Version

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

REPORT = """\
  Endpoint: mem_reg_12_
  Path Group: clk_core
  slack (MET)                                        0.042

  Endpoint: out_data_reg_7_
  Path Group: clk_core
  slack (VIOLATED)                                  -0.173

  Endpoint: ctrl_reg_2_
  Path Group: clk_cfg
  slack (VIOLATED)                                  -0.061

  Endpoint: mem_reg_3_
  Path Group: clk_core
  slack (MET)                                        0.231
"""

# v1: grep for the slack lines
SLACK = re.compile(r"slack \((MET|VIOLATED)\)\s+(-?\d+\.\d+)")

violations = [m.group(2) for m in SLACK.finditer(REPORT)
              if m.group(1) == "VIOLATED"]

print("v1 — grep 'slack (VIOLATED)'")
print("  violations found :", len(violations))
print("  the numbers      :", violations)
print("  worst            :", min(float(v) for v in violations))
print()
print("what it tells you  :", "there are 2 violations and the worst is -0.173")
print("what it cannot say :", "which endpoints, which corner, which clock group")
print("so the bug report  :", "'timing is failing by 173ps somewhere'")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
v1 — grep 'slack (VIOLATED)'
  violations found : 2
  the numbers      : ['-0.173', '-0.061']
  worst            : -0.173

what it tells you  : there are 2 violations and the worst is -0.173
what it cannot say : which endpoints, which corner, which clock group
so the bug report  : 'timing is failing by 173ps somewhere'

That is already more useful than nothing, and it is where most home-made timing scripts stop. The number is right. The last line is what makes it unusable: somewhere is not an actionable report, and the endpoint name is sitting in the file two lines above the number the script just extracted.

4. The Limitation: No Line Has Two Facts

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

REPORT = """\
  Startpoint: wr_ptr_reg[3]
  Endpoint: mem_reg_12_
  Path Group: clk_core
  Path Type: max
  slack (MET)                                        0.042

  Startpoint: rd_ptr_reg[0]
  Endpoint: out_data_reg_7_
  Path Group: clk_core
  Path Type: max
  slack (VIOLATED)                                  -0.173

  Startpoint: cfg_reg[1]
  Endpoint: ctrl_reg_2_
  Path Group: clk_cfg
  Path Type: max
  slack (VIOLATED)                                  -0.061

  Startpoint: wr_ptr_reg[1]
  Endpoint: mem_reg_3_
  Path Group: clk_core
  Path Type: max
  slack (MET)                                        0.231
"""

FIELDS = {
    "Endpoint": re.compile(r"Endpoint:\s*(\S+)"),
    "Path Group": re.compile(r"Path Group:\s*(\S+)"),
    "slack": re.compile(r"slack \((MET|VIOLATED)\)\s+(-?\d+\.\d+)"),
}

print(f"{'field':<12} {'lines carrying it':>18}")
for name, pat in FIELDS.items():
    n = sum(1 for line in REPORT.splitlines() if pat.search(line))
    print(f"{name:<12} {n:>18}")

both = sum(1 for line in REPORT.splitlines()
           if sum(1 for p in FIELDS.values() if p.search(line)) > 1)
print()
print("lines carrying MORE THAN ONE field:", both)
print("so a line-at-a-time parser can produce:", "one field at a time")
print("and the annotation's record needs    :", 3)
print()
print("the only way to join them:", "remember what you saw earlier")
print("which makes the parser  :", "stateful")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
field         lines carrying it
Endpoint                      4
Path Group                    4
slack                         4

lines carrying MORE THAN ONE field: 0
so a line-at-a-time parser can produce: one field at a time
and the annotation's record needs    : 3

the only way to join them: remember what you saw earlier
which makes the parser  : stateful

Zero lines carry more than one field. Four endpoints, four path groups, four slacks, and no line that joins any two of them — so the Timing record in the annotation is not extractable by any amount of cleverness applied to a single line.

That is the limitation that forces the architecture, and it is a different kind from Chapter 35.1's. There the problem was which line to use; here the problem is that the information is spread across lines and the parser has to accumulate it, which is Chapter 19.15's stateful multi-line parsing arriving on a real input.

5. The Architecture

Split the report, carry the state, aggregate per corner

Six blocks. Top row, left to right: the raw report text; a corner header reader that extracts the corner name; and a block splitter that cuts the report into one chunk per timing path. An arrow then runs downward from the splitter to the bottom row. Bottom row, right to left: a field extractor that pulls endpoint, path group and slack from one block into a Path record; an aggregator that computes worst negative slack and total negative slack per corner; and the Timing record keyed by corner.Report texta fixture — §1Read the cornerone header lineSplit into blocksone per pathExtract fieldsendpoint · group · slackAggregateWNS, TNS — §7Timing per cornerthe annotation's valueone blockrecords12
The block splitter is what makes the state machine simple: each path block is handed over whole, so the only state the extractor carries is within one block and is reset by construction. The arithmetic is a separate stage because Section 7 shows it is where the real mistakes live.

The block splitter is the design decision that keeps the state machine manageable. Chapter 19.15's stateful parsers carry a mutable current record across the whole file and reset it when they think a record has ended — and getting that reset wrong is how a field from one record leaks into the next. Splitting first means each block is parsed independently, so there is no reset to forget.

6. The Stateful Parser

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re
from dataclasses import dataclass

CORNER = re.compile(r"^\s*Corner\s*:\s*(\S+)", re.M)
ENDPOINT = re.compile(r"Endpoint:\s*(\S+)")
STARTPOINT = re.compile(r"Startpoint:\s*(\S+)")
GROUP = re.compile(r"Path Group:\s*(\S+)")
SLACK = re.compile(r"slack \((MET|VIOLATED)\)\s+(-?\d+\.\d+)")


@dataclass(frozen=True)
class Path:
    startpoint: str
    endpoint: str
    group: str
    slack: float
    state: str


def split_blocks(text):
    """One chunk per timing path. A block starts at 'Startpoint:'."""
    blocks, current = [], None
    for line in text.splitlines():
        if STARTPOINT.search(line):
            if current is not None:
                blocks.append("\n".join(current))
            current = [line]
        elif current is not None:
            current.append(line)
    if current is not None:
        blocks.append("\n".join(current))
    return blocks


def parse_block(block):
    """Returns a Path, or None for a block with no slack line."""
    s, e = STARTPOINT.search(block), ENDPOINT.search(block)
    g, k = GROUP.search(block), SLACK.search(block)
    if not (s and e and k):
        return None
    return Path(startpoint=s.group(1), endpoint=e.group(1),
                group=g.group(1) if g else "unknown",
                slack=float(k.group(2)), state=k.group(1))


REPORT = """\
****************************************
Corner : ss_0p81v_125c
****************************************

  Startpoint: wr_ptr_reg[3]
  Endpoint: mem_reg_12_
  Path Group: clk_core
  Path Type: max
  ------------------------------------------------------------
  slack (MET)                                        0.042

  Startpoint: rd_ptr_reg[0]
  Endpoint: out_data_reg_7_
  Path Group: clk_core
  Path Type: max
  ------------------------------------------------------------
  slack (VIOLATED)                                  -0.173

  Startpoint: cfg_reg[1]
  Endpoint: ctrl_reg_2_
  Path Group: clk_cfg
  Path Type: max
  ------------------------------------------------------------
  slack (VIOLATED)                                  -0.061

  Startpoint: wr_ptr_reg[1]
  Endpoint: mem_reg_3_
  Path Group: clk_core
  Path Type: max
  ------------------------------------------------------------
  slack (MET)                                        0.231
"""

corner = CORNER.search(REPORT).group(1)
blocks = split_blocks(REPORT)
paths = [p for p in (parse_block(b) for b in blocks) if p is not None]

print("corner          :", corner)
print("blocks split out:", len(blocks))
print("paths parsed    :", len(paths))
print()
print(f"{'endpoint':<18} {'group':<10} {'slack':>7}  state")
for p in paths:
    print(f"{p.endpoint:<18} {p.group:<10} {p.slack:>7.3f}  {p.state}")
print()
print("the header block produced a Path:", "no — it has no Startpoint")
print("blocks with no slack line       :", len(blocks) - len(paths))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
corner          : ss_0p81v_125c
blocks split out: 4
paths parsed    : 4

endpoint           group        slack  state
mem_reg_12_        clk_core     0.042  MET
out_data_reg_7_    clk_core    -0.173  VIOLATED
ctrl_reg_2_        clk_cfg     -0.061  VIOLATED
mem_reg_3_         clk_core     0.231  MET

the header block produced a Path: no — it has no Startpoint
blocks with no slack line       : 0

Four blocks, four paths, and the endpoint is now attached to its slack. The header is not a block at all, because split_blocks only opens one at a Startpoint: line — so the report's banner and corner line are discarded by construction rather than by a special case.

parse_block returns None rather than a partial record when the slack line is missing, which is the shape Chapter 33.5 Section 8 argued for: a block the tool was still writing produces no Path, and the caller filters it out with one comprehension. A truncated report loses its last path and reports the rest correctly.

7. WNS, TNS, and the Mistake That Flips the Verdict

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
SLACKS = [0.042, -0.173, -0.061, 0.231]
ENDPOINTS = ["mem_reg_12_", "out_data_reg_7_", "ctrl_reg_2_", "mem_reg_3_"]

negative = [s for s in SLACKS if s < 0]

print("the corner's slacks   :", SLACKS)
print("the negative ones     :", negative)
print()
print("WNS = min(all slacks) :", min(SLACKS))
print("TNS = sum(negatives)  :", round(sum(negative), 3))
print("TNS = sum(ALL slacks) :", round(sum(SLACKS), 3), " <- the mistake")
print()
print("violating endpoints   :",
      [e for e, s in zip(ENDPOINTS, SLACKS) if s < 0])
print()
GATE = "a sign-off gate that checks TNS < 0"
print(GATE)
print("  with the correct TNS:", round(sum(negative), 3), "-> fails, correctly")
print("  with the mistake    :", round(sum(SLACKS), 3), "-> PASSES")
print()
print("the corner really has :", len(negative), "violations")
print("the error is          :", "not numeric drift — it flips the verdict")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the corner's slacks   : [0.042, -0.173, -0.061, 0.231]
the negative ones     : [-0.173, -0.061]

WNS = min(all slacks) : -0.173
TNS = sum(negatives)  : -0.234
TNS = sum(ALL slacks) : 0.039  <- the mistake

violating endpoints   : ['out_data_reg_7_', 'ctrl_reg_2_']

a sign-off gate that checks TNS < 0
  with the correct TNS: -0.234 -> fails, correctly
  with the mistake    : 0.039 -> PASSES

the corner really has : 2 violations
the error is          : not numeric drift — it flips the verdict

Summing every slack gives a positive TNS for a corner with two real violations. 0.042 + 0.231 from the two met paths is larger than the -0.173 - 0.061 of the violations, so the total is +0.039 and a gate written as TNS < 0 passes a corner that is failing.

This is the defining property of TNS and the reason it is defined the way it is: TNS is the total amount of work remaining, so paths that already meet timing contribute nothing. Adding their positive slack means a design with a few bad paths and a lot of easy margin reports as clean, and the more margin the rest of the design has, the more violation it can hide.

8. A Clean Corner Has a Positive WNS

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CORNERS = {
    "ss_0p81v_125c": [0.042, -0.173, -0.061, 0.231],
    "tt_0p90v_25c": [0.042, 0.231, 0.118],
    "ff_0p99v_m40c": [0.511, 0.602, 0.489],
}


def summarise(slacks, clamp_wns):
    neg = [s for s in slacks if s < 0]
    wns = min(slacks)
    if clamp_wns:
        wns = min(wns, 0.0)
    return wns, round(sum(neg), 3), len(neg)


print(f"{'corner':<16} {'WNS':>8} {'clamped':>9} {'TNS':>8} {'viol':>5}")
for name, slacks in CORNERS.items():
    wns, tns, n = summarise(slacks, clamp_wns=False)
    cw, _, _ = summarise(slacks, clamp_wns=True)
    print(f"{name:<16} {wns:>8.3f} {cw:>9.3f} {tns:>8.3f} {n:>5}")

print()
print("the clamped column loses:", "the margin on every clean corner")
print("tt margin really is     :", 0.042)
print("the clamp reports       :", 0.0)
print()
print("WNS is                  :", "the worst SLACK")
print("WNS is not              :", "the worst VIOLATION")
print("and a clean corner's WNS:", "is the headroom you have left")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
corner                WNS   clamped      TNS  viol
ss_0p81v_125c      -0.173    -0.173   -0.234     2
tt_0p90v_25c        0.042     0.000    0.000     0
ff_0p99v_m40c       0.489     0.000    0.000     0

the clamped column loses: the margin on every clean corner
tt margin really is     : 0.042
the clamp reports       : 0.0

WNS is                  : the worst SLACK
WNS is not              : the worst VIOLATION
and a clean corner's WNS: is the headroom you have left

Clamping WNS to zero reports 0.000 for a corner with 42 picoseconds of real margin. The clamp is a tempting simplification — "no violation means no negative slack, so report zero" — and it throws away the single most useful number on a clean corner: how close it came.

The two clean corners show why that matters. tt has 42 ps of headroom and ff has 489 ps; clamped, they are identical, and the corner that is one ECO away from failing looks exactly as safe as the one with ten times the margin. Chapter 29.4's run-over-run delta is impossible to compute from a clamped number too, because a corner going from 0.042 to 0.011 shows no change at all.

TNS is correctly zero on a clean corner, because there is no remaining work. That asymmetry is the whole distinction: TNS sums a deficit and is zero when there is none; WNS reports a position on a scale and is meaningful in both directions.

9. The Analyzer, Assembled

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re
from dataclasses import dataclass

CORNER = re.compile(r"^\s*Corner\s*:\s*(\S+)", re.M)
STARTPOINT = re.compile(r"Startpoint:\s*(\S+)")
ENDPOINT = re.compile(r"Endpoint:\s*(\S+)")
GROUP = re.compile(r"Path Group:\s*(\S+)")
SLACK = re.compile(r"slack \((MET|VIOLATED)\)\s+(-?\d+\.\d+)")


@dataclass(frozen=True)
class Path:
    startpoint: str
    endpoint: str
    group: str
    slack: float
    state: str


@dataclass(frozen=True)
class Timing:
    wns: float
    tns: float
    endpoints: tuple
    paths: int
    groups: tuple

    @property
    def violated(self):
        return self.wns < 0


def split_blocks(text):
    blocks, current = [], None
    for line in text.splitlines():
        if STARTPOINT.search(line):
            if current is not None:
                blocks.append("\n".join(current))
            current = [line]
        elif current is not None:
            current.append(line)
    if current is not None:
        blocks.append("\n".join(current))
    return blocks


def parse_block(block):
    s, e, k = (STARTPOINT.search(block), ENDPOINT.search(block),
               SLACK.search(block))
    if not (s and e and k):
        return None
    g = GROUP.search(block)
    return Path(s.group(1), e.group(1), g.group(1) if g else "unknown",
                float(k.group(2)), k.group(1))


def summarise(paths):
    """WNS is the worst slack. TNS sums ONLY the negative ones — §7."""
    if not paths:
        return Timing(wns=float("nan"), tns=0.0, endpoints=(), paths=0,
                      groups=())
    slacks = [p.slack for p in paths]
    violating = sorted((p for p in paths if p.slack < 0),
                       key=lambda p: (p.slack, p.endpoint))
    return Timing(
        wns=min(slacks),
        tns=round(sum(p.slack for p in paths if p.slack < 0), 3),
        endpoints=tuple(p.endpoint for p in violating),
        paths=len(paths),
        groups=tuple(sorted({p.group for p in paths})),
    )


def analyze(reports):
    """The annotation. reports: {name: report text} -> {corner: Timing}."""
    out = {}
    for name in sorted(reports):
        text = reports[name]
        m = CORNER.search(text)
        corner = m.group(1) if m else name
        paths = [p for p in (parse_block(b) for b in split_blocks(text))
                 if p is not None]
        out[corner] = summarise(paths)
    return out


def block(sp, ep, grp, state, slack):
    return (f"  Startpoint: {sp}\n  Endpoint: {ep}\n  Path Group: {grp}\n"
            f"  Path Type: max\n  ---------------\n"
            f"  slack ({state}){slack:>40.3f}\n")


REPORTS = {
    "slow": ("Corner : ss_0p81v_125c\n"
             + block("wr_ptr_reg[3]", "mem_reg_12_", "clk_core", "MET", 0.042)
             + block("rd_ptr_reg[0]", "out_data_reg_7_", "clk_core",
                     "VIOLATED", -0.173)
             + block("cfg_reg[1]", "ctrl_reg_2_", "clk_cfg", "VIOLATED",
                     -0.061)
             + block("wr_ptr_reg[1]", "mem_reg_3_", "clk_core", "MET", 0.231)),
    "typical": ("Corner : tt_0p90v_25c\n"
                + block("wr_ptr_reg[3]", "mem_reg_12_", "clk_core", "MET",
                        0.042)
                + block("wr_ptr_reg[1]", "mem_reg_3_", "clk_core", "MET",
                        0.231)),
}

result = analyze(REPORTS)

print(f"{'corner':<16} {'WNS':>8} {'TNS':>8} {'paths':>6} {'viol':>5}  groups")
for corner in sorted(result):
    t = result[corner]
    print(f"{corner:<16} {t.wns:>8.3f} {t.tns:>8.3f} {t.paths:>6} "
          f"{len(t.endpoints):>5}  {','.join(t.groups)}")

print()
print("violating endpoints, worst first:")
for corner in sorted(result):
    if result[corner].endpoints:
        print(f"  {corner}: {list(result[corner].endpoints)}")

print()
print("corners analysed     :", len(result))
print("corners violated     :", sum(1 for t in result.values() if t.violated))
print("the gate's verdict   :",
      "FAIL" if any(t.violated for t in result.values()) else "PASS")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
corner                WNS      TNS  paths  viol  groups
ss_0p81v_125c      -0.173   -0.234      4     2  clk_cfg,clk_core
tt_0p90v_25c        0.042    0.000      2     0  clk_core

violating endpoints, worst first:
  ss_0p81v_125c: ['out_data_reg_7_', 'ctrl_reg_2_']

corners analysed     : 2
corners violated     : 1
the gate's verdict   : FAIL

Two corners, one violated, and the report names the two endpoints worst first. That is the difference from Section 3: out_data_reg_7_ is something a designer can open in a timing viewer, where "failing by 173ps somewhere" is not.

Three details are deliberate. The endpoints are sorted by (slack, endpoint), so the worst is first and ties break reproducibly — Chapter 33.14 Section 6's rule. groups is included because a violation confined to clk_cfg is often a constraint problem rather than a design one, and knowing that before opening the viewer saves an hour. And summarise on an empty path list returns wns=nan rather than 0.0, so a corner whose report failed to parse cannot be mistaken for a corner that passed.

10. How It Is Tested

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import io
import math
import re
import unittest
from dataclasses import dataclass

STARTPOINT = re.compile(r"Startpoint:\s*(\S+)")
ENDPOINT = re.compile(r"Endpoint:\s*(\S+)")
GROUP = re.compile(r"Path Group:\s*(\S+)")
SLACK = re.compile(r"slack \((MET|VIOLATED)\)\s+(-?\d+\.\d+)")
CORNER = re.compile(r"^\s*Corner\s*:\s*(\S+)", re.M)


@dataclass(frozen=True)
class Path:
    startpoint: str
    endpoint: str
    group: str
    slack: float
    state: str


@dataclass(frozen=True)
class Timing:
    wns: float
    tns: float
    endpoints: tuple
    paths: int


def split_blocks(text):
    blocks, cur = [], None
    for line in text.splitlines():
        if STARTPOINT.search(line):
            if cur is not None:
                blocks.append("\n".join(cur))
            cur = [line]
        elif cur is not None:
            cur.append(line)
    if cur is not None:
        blocks.append("\n".join(cur))
    return blocks


def parse_block(b):
    s, e, k = STARTPOINT.search(b), ENDPOINT.search(b), SLACK.search(b)
    if not (s and e and k):
        return None
    g = GROUP.search(b)
    return Path(s.group(1), e.group(1), g.group(1) if g else "unknown",
                float(k.group(2)), k.group(1))


def summarise(paths):
    if not paths:
        return Timing(float("nan"), 0.0, (), 0)
    slacks = [p.slack for p in paths]
    viol = sorted((p for p in paths if p.slack < 0),
                  key=lambda p: (p.slack, p.endpoint))
    return Timing(min(slacks),
                  round(sum(s for s in slacks if s < 0), 3),
                  tuple(p.endpoint for p in viol), len(paths))


def analyze(reports):
    out = {}
    for name in sorted(reports):
        m = CORNER.search(reports[name])
        paths = [p for p in (parse_block(b)
                             for b in split_blocks(reports[name]))
                 if p is not None]
        out[m.group(1) if m else name] = summarise(paths)
    return out


def blk(sp, ep, grp, state, slack):
    return (f"  Startpoint: {sp}\n  Endpoint: {ep}\n  Path Group: {grp}\n"
            f"  ---------------\n  slack ({state}){slack:>40.3f}\n")


# --- golden extracts, each the real shape — 33.4
MIXED = ("Corner : ss_0p81v_125c\n"
         + blk("a", "mem_reg_12_", "clk_core", "MET", 0.042)
         + blk("b", "out_data_reg_7_", "clk_core", "VIOLATED", -0.173)
         + blk("c", "ctrl_reg_2_", "clk_cfg", "VIOLATED", -0.061)
         + blk("d", "mem_reg_3_", "clk_core", "MET", 0.231))
CLEAN = ("Corner : tt_0p90v_25c\n"
         + blk("a", "mem_reg_12_", "clk_core", "MET", 0.042))
NO_GROUP = ("Corner : x\n  Startpoint: a\n  Endpoint: e1\n"
            "  ---------------\n  slack (MET)   0.100\n")
TRUNCATED = ("Corner : y\n" + blk("a", "e1", "clk_core", "MET", 0.100)
             + "  Startpoint: b\n  Endpoint: e2\n  Path Group: clk_core\n")
EMPTY = "Corner : z\n****\n"


class Analyzer(unittest.TestCase):

    def test_endpoint_is_joined_to_its_slack(self):
        t = analyze({"r": MIXED})["ss_0p81v_125c"]
        self.assertEqual(t.endpoints, ("out_data_reg_7_", "ctrl_reg_2_"))

    def test_wns_is_the_worst_slack(self):
        self.assertAlmostEqual(analyze({"r": MIXED})["ss_0p81v_125c"].wns,
                               -0.173)

    def test_tns_sums_only_negative_slacks(self):
        """The §7 mistake: sum(all) would be +0.039."""
        t = analyze({"r": MIXED})["ss_0p81v_125c"]
        self.assertAlmostEqual(t.tns, -0.234)
        self.assertLess(t.tns, 0)

    def test_tns_is_not_the_sum_of_all_slacks(self):
        t = analyze({"r": MIXED})["ss_0p81v_125c"]
        self.assertNotAlmostEqual(t.tns, 0.039)

    def test_a_clean_corner_keeps_its_positive_wns(self):
        t = analyze({"r": CLEAN})["tt_0p90v_25c"]
        self.assertAlmostEqual(t.wns, 0.042)
        self.assertGreater(t.wns, 0)

    def test_a_clean_corner_has_zero_tns(self):
        self.assertEqual(analyze({"r": CLEAN})["tt_0p90v_25c"].tns, 0.0)

    def test_a_missing_path_group_does_not_lose_the_path(self):
        self.assertEqual(analyze({"r": NO_GROUP})["x"].paths, 1)

    def test_a_truncated_block_is_dropped_not_guessed(self):
        self.assertEqual(analyze({"r": TRUNCATED})["y"].paths, 1)

    def test_an_unparseable_corner_is_nan_not_zero(self):
        self.assertTrue(math.isnan(analyze({"r": EMPTY})["z"].wns))

    def test_endpoints_are_ordered_worst_first(self):
        t = analyze({"r": MIXED})["ss_0p81v_125c"]
        self.assertEqual(t.endpoints[0], "out_data_reg_7_")

    def test_two_corners_are_kept_apart(self):
        got = analyze({"a": MIXED, "b": CLEAN})
        self.assertEqual(sorted(got), ["ss_0p81v_125c", "tt_0p90v_25c"])


def run(case):
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
    return {"run": res.testsRun, "failures": len(res.failures),
            "ok": res.wasSuccessful()}


print("analyzer suite:", run(Analyzer))
print()
print("golden extracts   :", 5)
print("behaviours pinned :", 11)
print("STA licences used :", 0)
print()
print("the test that exists because of §7:")
print("  test_tns_is_not_the_sum_of_all_slacks")
print("asserting the WRONG value is absent, because:",
      "the right value alone would pass under the bug on other data")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
analyzer suite: {'run': 11, 'failures': 0, 'ok': True}

golden extracts   : 5
behaviours pinned : 11
STA licences used : 0

the test that exists because of §7:
  test_tns_is_not_the_sum_of_all_slacks
asserting the WRONG value is absent, because: the right value alone would pass under the bug on other data

Eleven behaviours pinned with five extracts and no licence. Every input is a string built by blk, so the suite runs anywhere — which is the property that makes a timing analyzer testable at all, given that the tool producing its input costs money and minutes.

The last two lines name an unusual test and the reason for it. test_tns_sums_only_negative_slacks asserts -0.234, which is correct and insufficient: a fixture whose met paths happened to sum to zero would give the same answer under both the right and the wrong formula. Asserting that the result is not +0.039 pins the specific wrong value this fixture produces under the bug, which is Chapter 33.9's point that a test earns its place by being able to fail — and the fixture was chosen so that the two formulas disagree in sign, not just in magnitude.

test_an_unparseable_corner_is_nan_not_zero is the other one worth copying. A corner whose report did not parse must not read as wns = 0.0, because zero is a plausible timing number and nan is not — Chapter 30.3 Section 5's rule that a missing result is reported as missing.

11. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
SLACKS = [0.042, -0.173, -0.061, 0.231]

# A
print("A:", min(SLACKS), max(SLACKS))

# B
print("B:", round(sum(SLACKS), 3), round(sum(s for s in SLACKS if s < 0), 3))

# C
CLEAN = [0.042, 0.231]
print("C:", min(CLEAN), sum(s for s in CLEAN if s < 0))

# D
print("D:", sum([]), min([], default=float("nan")))

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: -0.173 0.231
B: 0.039 -0.234
C: 0.042 0
D: 0 nan

A — WNS is the min, and the max is of no interest: the best path in a design tells you nothing about whether it closes.

B — the two TNS formulas, side by side, with opposite signs. Section 7 in one line.

C — a clean corner: WNS is the positive 0.042 and TNS is 0. Note TNS is the integer 0 and not 0.0, because sum of an empty sequence returns int — harmless here, and a reason to round(..., 3) or format explicitly before putting it in a report where a column of 0 among -0.234 looks like a different kind of value.

D — and the empty case: sum([]) is 0 while min([]) raises unless given a default. That asymmetry is exactly why summarise special-cases an empty path list instead of letting the arithmetic decide — the TNS would come out as a confident 0 and the WNS would crash.

12. Exercises

Exercise 1 — count the gap. In a real report of your own, count the lines between an endpoint and its slack.

Exercise 2 — two numbers, two questions. Explain what TNS tells you that WNS does not.

Exercise 3 — the frozen record. Say why a signed-off timing number should be immutable.

Exercise 4 — your own v1. Grep for violated slacks and write the bug report it would support.

Exercise 5 — somewhere. Explain why the endpoint name changes that report.

Exercise 6 — no line has two facts. Measure it on your own report format.

Exercise 7 — split first. Write split_blocks and explain what reset bug it avoids.

Exercise 8 — the header is not a block. Say why the banner never becomes a path.

Exercise 9 — None, not partial. Explain what a partial record would do to your aggregate.

Exercise 10 — the two formulas. Compute TNS both ways on one of your corners.

Exercise 11 — opposite signs. Construct a corner where they disagree in sign.

Exercise 12 — the gate. Work out what a TNS < 0 gate does under the bug.

Exercise 13 — why TNS excludes met paths. Explain it in terms of remaining work.

Exercise 14 — margin hides violation. Say what happens as the rest of the design gets more margin.

Exercise 15 — clamp it. Clamp WNS to zero and list what you lose.

Exercise 16 — 42 against 489. Explain why two clean corners should not look identical.

Exercise 17 — the delta. Say why Chapter 29.4's run-over-run comparison needs an unclamped WNS.

Exercise 18 — the asymmetry. State why TNS is correctly zero on a clean corner and WNS is not.

Exercise 19 — worst first. Sort your violating endpoints and break ties reproducibly.

Exercise 20 — the path group. Say what a violation confined to one clock group usually means.

Exercise 21 — nan, not zero. Make an unparseable corner report nan and explain why.

Exercise 22 — five extracts. Build your own golden set from real report text.

Exercise 23 — assert the wrong value is absent. Add the assertNotAlmostEqual test and say what it catches.

Exercise 24 — a fixture that cannot tell. Construct one where both TNS formulas agree, and explain why it is a bad fixture.

Exercise 25 — no licence. Confirm your whole suite runs with no STA tool installed.

Exercise 26 — sum of empty. Check what sum([]) and min([]) do, and why that forces the empty special case.

Exercise 27 — per corner. Run your analyzer over three corners and produce the summary table.

13. Summary

  • No STA tool is installed here, so every report is a fixture written to the report_timing shape and labelled as one — Chapter 23.1 Section 1's convention.
  • Fifteen lines separate an endpoint from its slack in this block, and the gap grows with the path length — while no line carries more than one of the three fields the record needs. That is what makes the parser stateful.
  • wns and tns answer different questions — how bad the worst path is, and how much work remains — so reporting one is Chapter 29.2's single-threshold problem.
  • v1's answer is correct and unactionable: "failing by 173 ps somewhere", with the endpoint name two lines above the number it extracted.
  • Split into blocks before extracting fields, so each block is parsed independently and there is no reset to get wrong.
  • parse_block returns None rather than a partial record, so a truncated report loses its last path and reports the rest correctly.
  • TNS sums only the negative slacks. Summing all of them gave +0.039 for a corner with two real violations totalling −0.234, so a TNS < 0 gate passes a failing corner.
  • The more margin the rest of the design has, the more violation that mistake hides — which makes it worst late in a project, when a handful of paths are left.
  • A clean corner's WNS is positive. Clamping it to zero reported 0.000 for 42 ps of real margin and made a corner one ECO from failing look as safe as one with ten times the headroom.
  • TNS is correctly zero on a clean corner and WNS is not — TNS sums a deficit, WNS reports a position on a scale.
  • Name the violating endpoints, worst first, and include the path group, because a violation confined to one clock group is often a constraint problem.
  • An unparseable corner reports nan, not 0.0 — zero is a plausible timing number and nan is not.
  • Assert the wrong value is absent. The correct TNS alone would pass under the bug on a different fixture, so the test pins +0.039 as forbidden and the fixture is chosen to make the two formulas disagree in sign.
  • sum([]) is 0 and min([]) raises, which is why the empty case is special-cased rather than left to the arithmetic.

The next chapter moves from reading a tool's output to running the tool: one command that compiles, simulates, classifies and exits with a status the flow can test.

Where this fits

Part of the Python curriculum.