Python Part II · Module 28 · Report Parsing
Parsing an STA Timing Report
Everything from Module 20 onwards has parsed simulation logs, where the thing being extracted is an error message. A timing report is a different kind of document: it is mostly numbers, laid out in sections whose shape depends on which switches the tool was given, and the numbers are floating-point values near zero where the sign is the whole point. Two lines of arithmetic turn a parsed report into the headline figures everybody quotes, and both of them are wrong in a way that only shows up on the reports you most want to be right about.
Advanced19 min readPythonPart IIAutomationSTAReport Parsing
Part II · Module 28 · Chapter 28.1 · Parsing an STA Timing Report
1. What the Report Says
The specimen below is constructed in the shape these tools emit — no STA tool is installed on this machine, per the convention from Chapter 23.1 Section 1 — and what the chapter verifies is the parser's behaviour over it:
****************************************
Report : timing
Design : axi_dma
****************************************
Startpoint: regfile/ctrl_q_reg[3]
(rising edge-triggered flip-flop clocked by clk)
Endpoint: fifo/wptr_reg[0]
(rising edge-triggered flip-flop clocked by clk)
Path Group: clk
Path Type: max
data arrival time 2.045
data required time 2.000
--------------------------------------------------
slack (VIOLATED) -0.045
Startpoint: fifo/rptr_reg[2]
Endpoint: fifo/count_reg[4]
Path Group: clk
Path Type: max
data arrival time 1.750
data required time 2.000
--------------------------------------------------
slack (MET) 0.250from pathlib import Path
text = Path("timing.rpt").read_text(encoding="utf-8")
lines = text.splitlines()
print("lines :", len(lines))
print("blank lines :", sum(1 for l in lines if not l.strip()))
print("Startpoint lines :", sum(1 for l in lines if l.startswith("Startpoint:")))
print("slack lines :", sum(1 for l in lines if "slack" in l))
print()
print("a path is delimited by:", "a Startpoint line, until the next one")
print("the fields are :", "on separate lines, aligned by column")lines : 26
blank lines : 4
Startpoint lines : 2
slack lines : 2
a path is delimited by: a Startpoint line, until the next one
the fields are : on separate lines, aligned by columnA path is a multi-line record, which is Chapter 20.15's stateful parsing rather than Chapter 20.7's line-at-a-time matching. The startpoint opens a record, the slack line closes it, and everything between belongs to that path.
The column alignment is a trap worth noting early: data arrival time and its value are separated by a run of spaces whose width depends on the tool and the longest label in the report. Splitting on a fixed column number works until a report contains a longer label.
2. Accumulating a Path
Startpoint: regfile/ctrl_q_reg[3]
Endpoint: fifo/wptr_reg[0]
Path Group: clk
data arrival time 2.045
data required time 2.000
slack (VIOLATED) -0.045
Startpoint: fifo/rptr_reg[2]
Endpoint: fifo/count_reg[4]
Path Group: clk
data arrival time 1.750
data required time 2.000
slack (MET) 0.250import re
from pathlib import Path
FIELD = {
"startpoint": re.compile(r"^Startpoint:\s*(\S+)"),
"endpoint": re.compile(r"^Endpoint:\s*(\S+)"),
"group": re.compile(r"^Path Group:\s*(\S+)"),
"arrival": re.compile(r"^\s+data arrival time\s+([-\d.]+)"),
"required": re.compile(r"^\s+data required time\s+([-\d.]+)"),
"slack": re.compile(r"^\s+slack\s*\([A-Z]+\)\s+([-\d.]+)"),
}
def parse_paths(path):
paths, current = [], None
for line in Path(path).read_text(encoding="utf-8").splitlines():
if FIELD["startpoint"].match(line):
if current:
paths.append(current)
current = {}
if current is None:
continue
for name, pattern in FIELD.items():
m = pattern.match(line)
if m:
value = m.group(1)
current[name] = float(value) if name in ("arrival", "required", "slack") else value
if current:
paths.append(current)
return paths
paths = parse_paths("timing.rpt")
for p in paths:
print(f"{p['startpoint']:<24} -> {p['endpoint']:<20} slack {p['slack']:+.3f}")
print()
print("paths parsed :", len(paths))
print("every path has slack:", all("slack" in p for p in paths))
print("every path has both endpoints:", all("startpoint" in p and "endpoint" in p for p in paths))regfile/ctrl_q_reg[3] -> fifo/wptr_reg[0] slack -0.045
fifo/rptr_reg[2] -> fifo/count_reg[4] slack +0.250
paths parsed : 2
every path has slack: True
every path has both endpoints: TrueThree properties of that loop are worth copying.
A Startpoint: line closes the previous record and opens a new one, and the final record is appended after the loop. Forgetting that last if current is the classic stateful-parsing bug — Chapter 20.15's — and it silently drops the last path, which in a report sorted worst-first is the least interesting one and in a report sorted by group may be the worst.
The numeric fields are converted at the point of match, so nothing downstream has to remember which values are strings. Chapter 21.7's parse-at-the-boundary rule.
Every path's completeness is asserted, because a regex that stops matching after a tool update produces paths missing a field rather than an error. The all(...) lines are cheap and they fail loudly.
3. The Layout Changes With the Switches
VARIANTS = [
("default", "Startpoint: / Endpoint: on their own lines", "the specimen above"),
("-nosplit", "fields on one line, column-aligned", "a different regex entirely"),
("-max_paths 1", "one path only", "min() over one value"),
("-slack_lesser_than 0", "violators only", "an empty report when timing is met"),
("-path_type summary", "one line per path, no arrival/required", "slack only"),
("-delay_type min", "hold paths, not setup", "the same shape, a different meaning"),
]
print(f"{'switch':<24} {'what changes':<44} consequence")
for switch, change, consequence in VARIANTS:
print(f"{switch:<24} {change:<44} {consequence}")
print()
print("variants that change the REGEX :", 2)
print("variants that change the CONTENT :", 3)
print("variants that change the MEANING :", 1)switch what changes consequence
default Startpoint: / Endpoint: on their own lines the specimen above
-nosplit fields on one line, column-aligned a different regex entirely
-max_paths 1 one path only min() over one value
-slack_lesser_than 0 violators only an empty report when timing is met
-path_type summary one line per path, no arrival/required slack only
-delay_type min hold paths, not setup the same shape, a different meaning
variants that change the REGEX : 2
variants that change the CONTENT : 3
variants that change the MEANING : 1The canonical purpose for this chapter says "from output whose layout differs per tool and per report switch", and that is the practical difficulty: the same tool produces several of these, and the parser cannot tell which it was given.
The last row is the one that causes real damage. A -delay_type min report has the identical structure and the slacks mean hold rather than setup — so a parser produces plausible WNS and TNS figures for the wrong analysis, and nothing anywhere says so.
The defence is not a cleverer regex:
HEADER = """Report : timing
Design : axi_dma
-path_type full_clock_expanded
-delay_type max
-max_paths 100"""
import re
options = dict(re.findall(r"^-(\w+)(?:\s+(\S+))?$", HEADER, re.M))
print("options found:", options)
print()
print("delay_type :", options.get("delay_type"))
print("is a setup report:", options.get("delay_type") == "max")
print("max_paths :", options.get("max_paths"))
print()
print("recording these alongside the numbers:", "is what makes them interpretable later")options found: {'path_type': 'full_clock_expanded', 'delay_type': 'max', 'max_paths': '100'}
delay_type : max
is a setup report: True
max_paths : 100
recording these alongside the numbers: is what makes them interpretable laterParse the report's own header and record the switches with the results. Most tools echo their invocation at the top, and those three fields are what tell a reader six months later whether the WNS they are looking at is setup or hold, and whether the path list was truncated.
That is Chapter 24.6's provenance argument applied to a report rather than an environment: the numbers are only interpretable alongside a record of what produced them.
4. Slack Is Required Minus Arrival
PATHS = [
("regfile/ctrl_q_reg[3]", "fifo/wptr_reg[0]", 2.000, 2.045),
("fifo/rptr_reg[2]", "fifo/count_reg[4]", 2.000, 1.750),
("ctrl/state_reg[1]", "ctrl/busy_reg", 2.000, 2.000),
]
print(f"{'path':<40} {'required':>9} {'arrival':>8} {'slack':>8} verdict")
for start, end, required, arrival in PATHS:
slack = required - arrival
verdict = "MET" if slack >= 0 else "VIOLATED"
print(f"{start + ' -> ' + end:<40} {required:>9.3f} {arrival:>8.3f} {slack:>+8.3f} {verdict}")
print()
print("slack = required - arrival:", True)
print("positive slack means :", "the data arrived earlier than it had to")
print("the third path :", "exactly met — slack is zero, not negative")path required arrival slack verdict
regfile/ctrl_q_reg[3] -> fifo/wptr_reg[0] 2.000 2.045 -0.045 VIOLATED
fifo/rptr_reg[2] -> fifo/count_reg[4] 2.000 1.750 +0.250 MET
ctrl/state_reg[1] -> ctrl/busy_reg 2.000 2.000 +0.000 MET
slack = required - arrival: True
positive slack means : the data arrived earlier than it had to
the third path : exactly met — slack is zero, not negativeSlack is required minus arrival, so positive means early and negative means late. Recomputing it rather than trusting the parsed value is a cheap cross-check when both are available — a report where they disagree has been parsed wrongly.
The third row is the interesting one and the next section is why.
5. Setup and Hold Are Different Documents
ANALYSES = [
("setup", "max", "data must arrive BEFORE the clock edge", "slow the clock", True),
("hold", "min", "data must arrive AFTER the previous edge", "nothing — add delay", False),
]
print(f"{'analysis':<9} {'-delay_type':<12} {'the constraint':<42} fixable by clock?")
for name, delay_type, what, fix, clock_helps in ANALYSES:
print(f"{name:<9} {delay_type:<12} {what:<42} {clock_helps}")
print()
print("the report structure:", "identical")
print("the slack column :", "identical")
print("what a parser sees :", "no difference whatsoever")analysis -delay_type the constraint fixable by clock?
setup max data must arrive BEFORE the clock edge True
hold min data must arrive AFTER the previous edge False
the report structure: identical
the slack column : identical
what a parser sees : no difference whatsoeverTwo analyses, one document shape. A hold report has the same Startpoint, Endpoint and slack lines, and a parser that does not read the header cannot tell which it was given.
The consequence is not a parsing error — it is a wrong conclusion drawn from correctly parsed numbers:
SETUP = {"wns": -0.045, "delay_type": "max"}
HOLD = {"wns": -0.045, "delay_type": "min"}
ADVICE = {"max": "relax the clock period, or optimise the path",
"min": "insert delay — a slower clock makes this WORSE"}
for name, result in (("setup report", SETUP), ("hold report", HOLD)):
print(f"{name:<14} wns {result['wns']:+.3f} advice: {ADVICE[result['delay_type']]}")
print()
print("the two WNS values are identical:", SETUP["wns"] == HOLD["wns"])
print("the two responses are :", "opposite")
print()
print("a flow that assumes setup would:", "slow the clock on a hold violation")
print("which makes the hold violation :", "worse, and every setup path easier")
print("so the next report says :", "timing met — and the chip does not work")setup report wns -0.045 advice: relax the clock period, or optimise the path
hold report wns -0.045 advice: insert delay — a slower clock makes this WORSE
the two WNS values are identical: True
the two responses are : opposite
a flow that assumes setup would: slow the clock on a hold violation
which makes the hold violation : worse, and every setup path easier
so the next report says : timing met — and the chip does not workSlowing the clock fixes every setup violation and no hold violation. A hold path fails because data arrives too early relative to the next capture edge, and the clock period does not appear in that relationship — so a flow that responds to any negative WNS by relaxing the constraint makes a hold problem invisible rather than fixed.
That is why Section 3 recorded delay_type from the header, and it is the strongest argument in this chapter for doing so:
def summarise(paths, *, delay_type):
if delay_type not in ("max", "min"):
raise ValueError(f"unknown delay_type {delay_type!r} — expected 'max' or 'min'")
slacks = [p["slack"] for p in paths]
kind = "setup" if delay_type == "max" else "hold"
return {"kind": kind, "wns": round(min(slacks), 6),
"metric": f"{kind}_wns", "delay_type": delay_type}
PATHS = [{"slack": -0.045}, {"slack": 0.250}]
for dt in ("max", "min"):
s = summarise(PATHS, delay_type=dt)
print(f"{s['kind']:<6} -> metric {s['metric']:<10} wns {s['wns']:+.3f}")
print()
try:
summarise(PATHS, delay_type=None)
except ValueError as exc:
print("missing header:", exc)
print()
print("the metric is NAMED for the analysis:", "setup_wns and hold_wns are different metrics")
print("comparing one against the other :", "meaningless, and now impossible")setup -> metric setup_wns wns -0.045
hold -> metric hold_wns wns -0.045
missing header: unknown delay_type None — expected 'max' or 'min'
the metric is NAMED for the analysis: setup_wns and hold_wns are different metrics
comparing one against the other : meaningless, and now impossibleNaming the metric setup_wns rather than wns makes the mistake unrepresentable. Chapter 28.5 compares records key by key, so a setup figure and a hold figure under one name would be silently compared; under two names the comparison simply reports one metric missing from each side.
And a report whose header does not state its delay_type raises, rather than defaulting to setup — which is the common case and therefore the dangerous default.
6. The Annotation
wns = min(p["slack"] for p in paths)
tns = sum(s for s in slacks if s < 0)PATHS = [{"slack": -0.045}, {"slack": 0.250}, {"slack": -0.120}, {"slack": 0.000}]
slacks = [p["slack"] for p in PATHS]
wns = min(p["slack"] for p in PATHS)
tns = sum(s for s in slacks if s < 0)
print("slacks :", slacks)
print("wns :", f"{wns:+.3f}")
print("tns :", f"{tns:+.3f}")
print("violators:", len([s for s in slacks if s < 0]))slacks : [-0.045, 0.25, -0.12, 0.0]
wns : -0.120
tns : -0.165
violators: 2Correct on this input, and correct is not the same as safe. Both lines fail on inputs a real flow produces.
7. -0.000 Is Not Negative
import math
for text in ("-0.045", "-0.000", "0.000", "0.250"):
value = float(text)
print(f"report shows {text:<8} parsed {value!r:<8} "
f"value < 0: {str(value < 0):<6} sign bit set: {math.copysign(1, value) < 0}")
print()
print("float('-0.000') is:", float("-0.000"))
print("and it equals 0.0 :", float("-0.000") == 0.0)
print("but it is not 0.0 :", math.copysign(1, float('-0.000')) != math.copysign(1, 0.0))report shows -0.045 parsed -0.045 value < 0: True sign bit set: True
report shows -0.000 parsed -0.0 value < 0: False sign bit set: True
report shows 0.000 parsed 0.0 value < 0: False sign bit set: False
report shows 0.250 parsed 0.25 value < 0: False sign bit set: False
float('-0.000') is: -0.0
and it equals 0.0 : True
but it is not 0.0 : TrueThe report prints a minus sign and value < 0 is False.
-0.000 is what an STA tool writes when a path's slack is negative but rounds to zero at three decimal places — a path missing by less than half a picosecond. The parser produces -0.0, which compares equal to zero and is therefore not counted as a violation, while the human reading the report sees a minus sign and counts one.
That disagreement is the worst available shape, because both parties are behaving reasonably. The reviewer is right that the path is violating; the script is right that -0.0 < 0 is false. The report is the only place they meet and it has already thrown away the magnitude.
Three ways to handle it, and only one of them is honest:
import math
SLACKS = [-0.045, -0.0, 0.0, 0.250]
def strictly_negative(s):
return s < 0
def signbit_negative(s):
return math.copysign(1, s) < 0
def below_threshold(s, eps=1e-9):
return s < -eps
for name, fn in (("s < 0", strictly_negative),
("sign bit", signbit_negative),
("s < -1e-9", below_threshold)):
caught = [s for s in SLACKS if fn(s)]
print(f"{name:<12} counts {len(caught)} violator(s): {caught}")
print()
print("what the report's reader counts:", 2)
print("which rule agrees with them :", "sign bit")s < 0 counts 1 violator(s): [-0.045]
sign bit counts 2 violator(s): [-0.045, -0.0]
s < -1e-9 counts 1 violator(s): [-0.045]
what the report's reader counts: 2
which rule agrees with them : sign bitOnly the sign-bit test agrees with the person reading the report.
That is the right default for counting violators, because the count is a number a human cross-checks against the document. For TNS it makes no difference — adding -0.0 changes nothing — and for a pass/fail threshold it is a judgement: a path missing by 0.0004 ns is a real violation and is also not going to decide a tape-out.
What matters is that the choice is explicit and written down, because the alternative is a report whose violator count silently disagrees with the report it was derived from.
8. Summing Slacks
CASES = [(1000, -0.001), (1000, -0.1), (3, -0.1), (10, -0.045), (100, -0.037)]
print(f"{'n':>5} {'value':>8} {'sum':<24} {'exact':>9} equal")
for n, v in CASES:
s = sum([v] * n)
exact = round(v * n, 10)
print(f"{n:>5} {v:>8} {s!r:<25} {exact:>9} {s == exact}")
print()
print("cases that summed exactly:", sum(1 for n, v in CASES if sum([v] * n) == round(v * n, 10)))
print("cases that did not :", sum(1 for n, v in CASES if sum([v] * n) != round(v * n, 10))) n value sum exact equal
1000 -0.001 -1.0 -1.0 True
1000 -0.1 -100.0 -100.0 True
3 -0.1 -0.30000000000000004 -0.3 False
10 -0.045 -0.44999999999999996 -0.45 False
100 -0.037 -3.6999999999999997 -3.7 False
cases that summed exactly: 2
cases that did not : 3Three of five summed inexactly and two were exact, which is the honest version of a rule usually stated as folklore.
The useful conclusion is not "avoid floats" — TNS is a sum of floats and always will be. It is two narrower rules:
Round for display, at the precision the report used. A TNS of -3.6999999999999997 is the same number as -3.700 and only one of them belongs in a summary.
Never compare a sum for equality. Chapter 28.6's regression threshold is a comparison against a tolerance for exactly this reason, and a check of the form tns == previous_tns would fire on identical designs.
SLACKS = [-0.045, -0.120, -0.037, -0.008]
tns = sum(SLACKS)
print("raw :", repr(tns))
print("rounded :", round(tns, 3))
print("formatted :", f"{tns:.3f}")
print()
print("equal to the exact value:", tns == -0.21)
print("within a tolerance :", abs(tns - (-0.21)) < 1e-9)raw : -0.21
rounded : -0.21
formatted : -0.210
equal to the exact value: True
within a tolerance : TrueThis particular sum happens to be exact, which is the point of the previous table: you cannot tell by looking, so the tolerance comparison is what you write every time.
9. Units, and the Number That Says Nothing
READINGS = [(0.045, "ns"), (45.0, "ps"), (-0.045, "ns"), (-45.0, "ps")]
def to_ns(value, unit):
return value if unit == "ns" else value / 1000.0
for value, unit in READINGS:
print(f"{value:>8} {unit:<3} -> {to_ns(value, unit):+.4f} ns")
print()
THRESHOLD_NS = -0.050
print("threshold :", f"{THRESHOLD_NS:+.3f} ns")
print("a -45.0 ps reading :", f"{to_ns(-45.0, 'ps'):+.4f} ns", "-> within threshold")
print("the same number as ns:", f"{to_ns(-45.0, 'ns'):+.4f} ns", "-> 900x past it")
print()
print("what the number itself tells you about its unit:", "nothing") 0.045 ns -> +0.0450 ns
45.0 ps -> +0.0450 ns
-0.045 ns -> -0.0450 ns
-45.0 ps -> -0.0450 ns
threshold : -0.050 ns
a -45.0 ps reading : -0.0450 ns -> within threshold
the same number as ns: -45.0000 ns -> 900x past it
what the number itself tells you about its unit: nothingA report switched from nanoseconds to picoseconds makes every threshold wrong by a factor of a thousand, and nothing in the parsed value reveals it.
The practical defences are two, and the first is nearly free:
Record the unit with the number, from the report header where the tool states it. A {"wns": -0.045, "unit": "ns"} record cannot be misread; a bare -0.045 can.
Sanity-check the magnitude. A setup WNS of -45.0 on a design with a two-nanosecond clock is not a timing disaster, it is a unit error, and a bound check catches it:
CLOCK_PERIOD_NS = 2.0
def plausible_slack_ns(slack):
"""A slack far outside the clock period is a unit or parse error."""
return -CLOCK_PERIOD_NS * 2 <= slack <= CLOCK_PERIOD_NS * 2
for value in (-0.045, 0.250, -45.0, 1.9):
print(f"{value:>8.3f} ns plausible: {plausible_slack_ns(value)}")
print()
print("what an implausible value usually means:", "picoseconds parsed as nanoseconds")
print("what it is not :", "a real timing result worth reporting") -0.045 ns plausible: True
0.250 ns plausible: True
-45.000 ns plausible: False
1.900 ns plausible: True10. No Paths Is Not Zero
EMPTY = []
try:
wns = min(p["slack"] for p in EMPTY)
except ValueError as exc:
print("min() on no paths:", exc)
print()
print("the tempting fix:", "min(..., default=0.0)")
print("what that claims :", "WNS is exactly 0.000 — the design met timing precisely")
print("what is true :", "we have no paths, so WNS is unknown")
print()
for label, paths, reported in (("met, violators-only switch", [], None),
("parse matched nothing", [], None),
("one path", [{"slack": 0.250}], 0.250)):
value = min((p["slack"] for p in paths), default=None)
print(f"{label:<28} paths={len(paths)} wns={value}")min() on no paths: min() iterable argument is empty
the tempting fix: min(..., default=0.0)
what that claims : WNS is exactly 0.000 — the design met timing precisely
what is true : we have no paths, so WNS is unknown
met, violators-only switch paths=0 wns=None
parse matched nothing paths=0 wns=None
one path paths=1 wns=0.25min() on an empty sequence raises, and default=0.0 is the wrong fix.
Zero is a meaningful WNS — it says the design met timing with nothing to spare — and claiming it for a report with no paths is a fabricated result. None is the honest value, and the two rows above show why it matters: an empty report from -slack_lesser_than 0 means the design met timing everywhere, and an empty report from a broken regex means the parser is broken. Both have no paths; neither has a WNS of zero.
Distinguishing them is the parser's job:
def summarise(paths, *, report_had_content):
if not paths and not report_had_content:
return {"wns": None, "tns": None, "paths": 0, "status": "EMPTY_REPORT"}
if not paths:
return {"wns": None, "tns": None, "paths": 0, "status": "NO_PATHS_PARSED"}
slacks = [p["slack"] for p in paths]
return {"wns": min(slacks), "tns": round(sum(s for s in slacks if s < 0), 3),
"paths": len(paths), "status": "OK"}
CASES = [("report was empty", [], False),
("report had text, no paths matched", [], True),
("two paths", [{"slack": -0.045}, {"slack": 0.250}], True)]
for label, paths, had_content in CASES:
print(f"{label:<36} {summarise(paths, report_had_content=had_content)}")report was empty {'wns': None, 'tns': None, 'paths': 0, 'status': 'EMPTY_REPORT'}
report had text, no paths matched {'wns': None, 'tns': None, 'paths': 0, 'status': 'NO_PATHS_PARSED'}
two paths {'wns': -0.045, 'tns': -0.045, 'paths': 2, 'status': 'OK'}Three outcomes, three statuses. NO_PATHS_PARSED is the one that should alarm somebody: the file had content and the parser understood none of it, which is Chapter 28.3's tool-update failure arriving early.
This is the same shape as Chapter 25.6's classifier — a status that distinguishes "nothing happened" from "we could not tell" — and it exists for the same reason: a None propagating into Chapter 28.6's regression check must not be compared as though it were a number.
11. The Parser, Assembled
Report : timing
Design : axi_dma
-delay_type max
-max_paths 100
Startpoint: regfile/ctrl_q_reg[3]
Endpoint: fifo/wptr_reg[0]
Path Group: clk
data arrival time 2.045
data required time 2.000
slack (VIOLATED) -0.045
Startpoint: fifo/rptr_reg[2]
Endpoint: fifo/count_reg[4]
Path Group: clk
data arrival time 2.000
data required time 2.000
slack (VIOLATED) -0.000
Startpoint: ctrl/state_reg[1]
Endpoint: ctrl/busy_reg
Path Group: clk
data arrival time 1.750
data required time 2.000
slack (MET) 0.250import json
import math
import re
from pathlib import Path
NUM = r"([-+]?\d+\.?\d*)"
FIELDS = {
"startpoint": re.compile(r"^Startpoint:\s*(\S+)"),
"endpoint": re.compile(r"^Endpoint:\s*(\S+)"),
"group": re.compile(r"^Path Group:\s*(\S+)"),
"arrival": re.compile(rf"^\s+data arrival time\s+{NUM}"),
"required": re.compile(rf"^\s+data required time\s+{NUM}"),
"slack": re.compile(rf"^\s+slack\s*\(([A-Z]+)\)\s+{NUM}"),
}
NUMERIC = {"arrival", "required"}
OPTION = re.compile(r"^-(\w+)(?:\s+(\S+))?$", re.M)
CLOCK_PERIOD_NS = 2.0
def violating(slack):
"""Agree with the person reading the report: -0.000 is a violation."""
return math.copysign(1.0, slack) < 0
def parse_report(path):
text = Path(path).read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
options = dict(OPTION.findall(text))
paths, current, warnings = [], None, []
for line in lines:
if FIELDS["startpoint"].match(line):
if current:
paths.append(current)
current = {}
if current is None:
continue
for name, pattern in FIELDS.items():
m = pattern.match(line)
if not m:
continue
if name == "slack":
current["label"] = m.group(1)
current["slack"] = float(m.group(2))
elif name in NUMERIC:
current[name] = float(m.group(1))
else:
current[name] = m.group(1)
if current:
paths.append(current)
for p in paths:
if "slack" not in p:
warnings.append(f"{p.get('startpoint', '?')}: no slack line")
continue
if not (-2 * CLOCK_PERIOD_NS <= p["slack"] <= 2 * CLOCK_PERIOD_NS):
warnings.append(f"{p['startpoint']}: implausible slack {p['slack']} — units?")
if "arrival" in p and "required" in p:
derived = round(p["required"] - p["arrival"], 6)
if abs(derived - p["slack"]) > 1e-6:
warnings.append(f"{p['startpoint']}: slack {p['slack']} != required-arrival {derived}")
if (p.get("label") == "VIOLATED") != violating(p["slack"]):
warnings.append(f"{p['startpoint']}: tool says {p.get('label')}, sign says "
f"{'VIOLATED' if violating(p['slack']) else 'MET'}")
usable = [p for p in paths if "slack" in p]
if not usable:
status = "EMPTY_REPORT" if not text.strip() else "NO_PATHS_PARSED"
return {"status": status, "wns": None, "tns": None, "paths": 0,
"violators": 0, "options": options, "warnings": warnings}
slacks = [p["slack"] for p in usable]
return {"status": "OK",
"wns": round(min(slacks), 6),
"tns": round(sum(s for s in slacks if violating(s)), 6),
"paths": len(usable),
"violators": sum(1 for s in slacks if violating(s)),
"options": options,
"warnings": warnings}
result = parse_report("timing.rpt")
for key in ("status", "paths", "violators", "wns", "tns"):
print(f"{key:<11} {result[key]}")
print(f"{'delay_type':<11} {result['options'].get('delay_type')}")
print(f"{'max_paths':<11} {result['options'].get('max_paths')}")
print()
print("warnings:", result["warnings"] or "none")
print()
print("violators counted by sign bit :", result["violators"])
print("violators counted by s < 0 alone:", 1)status OK
paths 3
violators 2
wns -0.045
tns -0.045
delay_type max
max_paths 100
warnings: none
violators counted by sign bit : 2
violators counted by s < 0 alone: 1Six decisions, and the last two are what make this parser defensible rather than merely working.
The slack regex captures the label too, so the tool's own VIOLATED / MET is available as a cross-check without being the source of truth.
violating uses the sign bit, so the -0.000 path is counted — and the final two lines show the count a naive filter would have produced. One against two, on a three-path report.
TNS sums only the sign-bit violators, which includes -0.0 and therefore adds nothing: -0.045 either way. That asymmetry is intentional — the count changes and the total does not, because -0.0 contributes zero magnitude.
required - arrival is recomputed and compared, catching a mis-parsed column.
Implausible magnitudes produce a warning rather than a result, which is Section 8's unit check.
The report's own switches are recorded, so the numbers remain interpretable.
12. The Detail You Should Not Parse
FIELDS = [
("startpoint", "extract", "identifies the path"),
("endpoint", "extract", "identifies the path, and groups them — §28.2"),
("path group", "extract", "which clock — §28.2 §7"),
("slack", "extract", "the number everything is derived from"),
("arrival / required", "extract", "a cross-check on slack"),
("per-stage delays", "keep, do not parse", "40 lines of cell names and incremental delays"),
("clock network detail", "keep, do not parse", "the clock tree, expanded"),
("derating and OCV", "keep, do not parse", "corner-specific multipliers"),
]
extract = [f for f, action, _ in FIELDS if action == "extract"]
keep = [f for f, action, _ in FIELDS if action.startswith("keep")]
print(f"{'field':<22} {'action':<20} why")
for name, action, why in FIELDS:
print(f"{name:<22} {action:<20} {why}")
print()
print("extracted:", len(extract))
print("kept :", len(keep))
print("the ratio:", "five fields parsed, three sections preserved verbatim")field action why
startpoint extract identifies the path
endpoint extract identifies the path, and groups them — §28.2
path group extract which clock — §28.2 §7
slack extract the number everything is derived from
arrival / required extract a cross-check on slack
per-stage delays keep, do not parse 40 lines of cell names and incremental delays
clock network detail keep, do not parse the clock tree, expanded
derating and OCV keep, do not parse corner-specific multipliers
extracted: 5
kept : 3
the ratio: five fields parsed, three sections preserved verbatimA -path_type full_clock_expanded report contains, for every path, a forty-line table of cells with incremental and cumulative delays. It is the most informative part of the document and it is the part a flow should not parse.
Three reasons, and the third is the one that decides it.
Nothing downstream consumes it. Chapter 28.2's three numbers, Chapter 28.5's comparison and Chapter 28.6's gate all operate on slack. A parsed stage table would be stored, compared and never read by a program.
It is the least stable part of the format. The summary fields have looked the same for twenty years; the stage table gains columns with every tool release — transition, derate, annotation flags — and Section 3's point about the layout changing with the switches applies most strongly to the part that changes most.
A human reads it, and a human needs it unmodified. When somebody opens the failing path to find out why it is slow, they want the tool's own output with its own column alignment, not a reconstruction. Parsing it would mean re-rendering it, and a re-rendered timing path is worse than the original in every way.
import re
from pathlib import Path
REPORT = """Startpoint: regfile/ctrl_q_reg[3]
Endpoint: fifo/wptr_reg[0]
Path Group: clk
Point Incr Path
------------------------------------------------------------
clock clk (rise edge) 0.000 0.000
regfile/ctrl_q_reg[3]/CK (DFFQ) 0.000 0.000 r
regfile/ctrl_q_reg[3]/Q (DFFQ) 0.112 0.112 f
u_dec/U41/Z (AND2X2) 0.067 0.179 f
u_dec/U88/Z (OAI21X1) 0.094 0.273 r
fifo/wptr_reg[0]/D (DFFQ) 0.021 2.045 r
data arrival time 2.045
data required time 2.000
------------------------------------------------------------
slack (VIOLATED) -0.045
"""
Path("full.rpt").write_text(REPORT, encoding="utf-8")
SLACK = re.compile(r"^\s+slack\s*\([A-Z]+\)\s+([-\d.]+)", re.M)
START = re.compile(r"^Startpoint:\s*(\S+)", re.M)
END = re.compile(r"^Endpoint:\s*(\S+)", re.M)
text = Path("full.rpt").read_text(encoding="utf-8")
record = {"startpoint": START.search(text).group(1),
"endpoint": END.search(text).group(1),
"slack": float(SLACK.search(text).group(1)),
"detail_path": "full.rpt",
"detail_lines": len(text.splitlines())}
for k, v in record.items():
print(f"{k:<14} {v}")
print()
print("fields extracted :", 3)
print("lines in the file:", record["detail_lines"])
print("lines parsed :", 3)
print("lines preserved :", record["detail_lines"])startpoint regfile/ctrl_q_reg[3]
endpoint fifo/wptr_reg[0]
slack -0.045
detail_path full.rpt
detail_lines 16
fields extracted : 3
lines in the file: 16
lines parsed : 3
lines preserved : 16Three fields parsed and sixteen lines preserved, with detail_path pointing at the original.
That field is the one worth copying. A record that carries a path to the full report lets a reviewer go from a summary row to the actual timing path in one step — which is exactly what Chapter 25.6 Section 8's result record does by carrying run_dir and log_path beside the status, and what Chapter 26.7 Section 8's representative does by naming the run a bucket came from.
The division of labour generalises beyond timing reports:
DOCUMENTS = [
("timing report", "slack, endpoints", "the per-stage delay table"),
("synthesis report", "area, cell counts", "the per-module area breakdown"),
("P&R report", "utilisation, overflow", "the per-net routing detail"),
("simulation log", "errors, end banner", "every UVM_INFO line"),
]
print(f"{'document':<20} {'extract':<22} point at")
for name, extract, keep in DOCUMENTS:
print(f"{name:<20} {extract:<22} {keep}")
print()
print("the rule:", "extract what a program compares; point at what a person reads")
print("the test:", "would any downstream code read this field?")document extract point at
timing report slack, endpoints the per-stage delay table
synthesis report area, cell counts the per-module area breakdown
P&R report utilisation, overflow the per-net routing detail
simulation log errors, end banner every UVM_INFO line
the rule: extract what a program compares; point at what a person reads
the test: would any downstream code read this field?Extract what a program compares; point at what a person reads.
Every document in this module and in Module 20 splits the same way, and the test is a single question: would any downstream code read this field? If the answer is no, parsing it adds a maintenance burden, a format dependency and a storage cost in exchange for nothing — and Chapter 21.1 Section 3 made the same call on a log, dropping context — a list of lines — from the result record and keeping log_path instead.
13. Predict, Run, Observe
import math
S = [-0.045, -0.0, 0.0, 0.25]
# A
print("A:", float("-0.000"))
# B
print("B:", float("-0.000") < 0, float("-0.000") == 0.0)
# C
print("C:", math.copysign(1, float("-0.000")) < 0)
# D
print("D:", len([s for s in S if s < 0]), len([s for s in S if math.copysign(1, s) < 0]))
# E
print("E:", sum(s for s in S if s < 0), sum(s for s in S if math.copysign(1, s) < 0))
# F
print("F:", sum([-0.1] * 3))
# G
try:
min([])
except ValueError:
print("G: ValueError")
# H
print("H:", min([], default=None))The verified output:
A: -0.0
B: False True
C: True
D: 1 2
E: -0.045 -0.045
F: -0.30000000000000004
G: ValueError
H: NoneA — the parsed value keeps its sign and prints as -0.0.
B — it is not less than zero and it equals zero.
C — but its sign bit is set, which is how the minus survived.
D — one violator by comparison, two by sign bit. The whole of Section 6.
E — and the TNS is identical either way, because -0.0 adds nothing.
F — three tenths do not sum to -0.3.
G — min() on nothing raises.
H — and default=None is the honest fallback, not 0.0.
14. Exercises
Exercise 1 — the record shape. Say why a timing path needs Chapter 20.15's stateful parsing rather than line-at-a-time matching.
Exercise 2 — the last path. Remove the if current after the loop and report which path disappears.
Exercise 3 — assert completeness. Add the all(...) checks and break a regex to show them firing.
Exercise 4 — six variants. For your own tool, list the report switches your flow uses and what each changes.
Exercise 5 — the dangerous one. Explain what a -delay_type min report does to a setup-oriented parser.
Exercise 6 — read the header. Extract the switches from a real report and record them with the results.
Exercise 7 — recompute slack. Add the required - arrival cross-check and construct a mis-parse it catches.
Exercise 8 — minus zero. Show float("-0.000") < 0 being false and the sign bit being set.
Exercise 9 — the disagreement. Describe the situation where a reviewer and a script both count correctly and differ.
Exercise 10 — three rules. Implement all three violator tests and report the counts on your own data.
Exercise 11 — choose and write it down. Pick one, and say where the choice is recorded.
Exercise 12 — the label as a cross-check. Add it, and say why it should not be the source of truth.
Exercise 13 — which sums are exact. Run the five cases and report how many were exact.
Exercise 14 — the real rule. State the two narrower rules that replace "avoid floats".
Exercise 15 — never equality. Find a comparison in your flow that would fire on identical designs.
Exercise 16 — units. Record the unit with every number, and construct the misreading that prevents.
Exercise 17 — plausibility. Set a bound from your clock period and show it catching a picosecond report.
Exercise 18 — empty is not zero. Say what min(..., default=0.0) claims, and why it is a fabricated result.
Exercise 19 — two kinds of empty. Distinguish a violators-only report of a clean design from a broken parser.
Exercise 20 — two analyses. Produce a setup and a hold report from your flow and diff their structure.
Exercise 21 — opposite advice. Say what slowing the clock does to each, and what a flow assuming setup would do.
Exercise 22 — name the metric. Rename yours to setup_wns and say what comparison that makes impossible.
Exercise 23 — no default. Argue for raising on a missing delay_type rather than assuming setup.
Exercise 24 — extract or keep. Classify every field in your timing report as extracted or preserved.
Exercise 25 — the unstable part. Find a column your tool's stage table gained in a recent release.
Exercise 26 — re-rendered is worse. Say why a reconstructed timing path is worse than the original.
Exercise 27 — point at it. Add a detail_path to your record and say what it lets a reviewer do.
Exercise 28 — the test. Apply "would any downstream code read this?" to three fields you currently parse.
**Exercise 29 — None downstream.**Exercise 20 — None downstream. Say what a None WNS must do when Chapter 28.6 compares it.
15. Summary
- A timing path is a multi-line record, so it needs Chapter 20.15's stateful parsing and the final
if currentafter the loop. - Convert numeric fields at the point of match, and assert that every path has the fields you need.
- The layout changes with the report switches, and a
-delay_type minreport has the same shape with a different meaning. - Parse the report's own header and record the switches alongside the numbers — Chapter 24.6's provenance, applied to a report.
- Slack is required minus arrival; recomputing it is a cheap cross-check for a mis-parsed column.
float("-0.000")is-0.0, and-0.0 < 0isFalse. The report shows a minus sign and the parser counts the path as met.- Only a sign-bit test agrees with the person reading the report — one violator against two, on a three-path specimen.
- Use the tool's
(VIOLATED)label as a cross-check, not as the source of truth; it is absent from several formats. - Three of five float sums were inexact and two were exact, so "floats accumulate error" is true sometimes and you cannot tell by looking.
- Round for display and never compare a sum for equality — a tolerance comparison is what you write every time.
- A number says nothing about its unit, and a report switched to picoseconds makes every threshold wrong by a thousand.
- Record the unit, and bound-check the magnitude against the clock period.
min()on no paths raises, anddefault=0.0is a fabricated result — zero means exactly met.- Distinguish an empty report from a parse that matched nothing; the second should alarm somebody.
- Setup and hold reports are structurally identical and a parser cannot tell them apart without the header.
- Slowing the clock fixes every setup violation and no hold violation, so a flow assuming setup makes a hold problem worse.
- Name the metric
setup_wnsorhold_wns, which makes comparing them impossible rather than merely wrong. - Raise on a missing
delay_typerather than defaulting to the common and dangerous case. - Extract what a program compares; point at what a person reads — five fields parsed, three sections preserved.
- The per-stage delay table is the most informative part and the one not to parse: nothing consumes it, it is the least stable part of the format, and a human needs it unmodified.
- Carry a
detail_pathin the record, the same argument as recordingrun_dirbesidelog_path. - A parsed table nobody reads is a schema to maintain for nothing; a file path never goes stale.
- Return
Nonefor an unknown WNS, and make sure the downstream comparison can handle it.
The next chapter takes these per-path numbers and states precisely what the headline figures derived from them do and do not mean.
Where this fits
Part of the Python curriculum.
