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.
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))****************************************
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 : 15Fifteen 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
analyze(reports) -> {corner: Timing(wns, tns, endpoints)}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)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 : Truewns 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
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'")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
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")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 : statefulZero 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
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
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))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 : 0Four 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
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")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 verdictSumming 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
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")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 leftClamping 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
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")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 : FAILTwo 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
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")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 dataEleven 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
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:
A: -0.173 0.231
B: 0.039 -0.234
C: 0.042 0
D: 0 nanA — 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_timingshape 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.
wnsandtnsanswer 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_blockreturnsNonerather 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 < 0gate 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.000for 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, not0.0— zero is a plausible timing number andnanis 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.039as forbidden and the fixture is chosen to make the two formulas disagree in sign. sum([])is0andmin([])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.
