Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
timing.rpt
****************************************
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.250
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from 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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 column

A 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

Azvya Education Pvt. Ltd.VLSI Mentor
timing.rpt
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.250
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import 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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: True

Three 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 : 1

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 later

Parse 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 negative

Slack 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 whatsoever

Two 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 work

Slowing 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 impossible

Naming 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
wns = min(p["slack"] for p in paths)
tns = sum(s for s in slacks if s < 0)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
slacks   : [-0.045, 0.25, -0.12, 0.0]
wns      : -0.120
tns      : -0.165
violators: 2

Correct 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 : True

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 bit

Only 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    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       : 3

Three 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
raw        : -0.21
rounded    : -0.21
formatted  : -0.210

equal to the exact value: True
within a tolerance      : True

This 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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: nothing

A 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  -0.045 ns  plausible: True
   0.250 ns  plausible: True
 -45.000 ns  plausible: False
   1.900 ns  plausible: True

10. No Paths Is Not Zero

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.25

min() 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

Azvya Education Pvt. Ltd.VLSI Mentor
timing.rpt
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.250
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import 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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: 1

Six 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 verbatim

A -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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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"])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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  : 16

Three 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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?")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: -0.0
B: False True
C: True
D: 1 2
E: -0.045 -0.045
F: -0.30000000000000004
G: ValueError
H: None

A — 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 current after 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 min report 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 < 0 is False. 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, and default=0.0 is 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_wns or hold_wns, which makes comparing them impossible rather than merely wrong.
  • Raise on a missing delay_type rather 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_path in the record, the same argument as recording run_dir beside log_path.
  • A parsed table nobody reads is a schema to maintain for nothing; a file path never goes stale.
  • Return None for 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.