Python Part II · Module 28 · Report Parsing
Parsing a Synthesis Report
A synthesis report is mostly a table of totals, which makes it look like the easiest thing in this module to parse. It is the one where a wrong answer is hardest to notice: a timing slack that parses wrongly is usually implausible, and an area that parses wrongly is just a number. The regex everybody writes for it is correct on one of the spellings a tool uses and silently produces a believable wrong value on another — and the harder half of the chapter is which of these numbers can be compared between two runs at all.
Advanced19 min readPythonPart IIAutomationSynthesisReport Parsing
Part II · Module 28 · Chapter 28.3 · Parsing a Synthesis Report
1. What the Report Says
The specimen is constructed in the shape these tools emit — no synthesis tool is installed here, per Chapter 23.1 Section 1:
****************************************
Report : area
Design : axi_dma
Library: tcbn05_base_lvt_ss0p675v125c
****************************************
Number of ports: 1842
Number of nets: 48213
Number of cells: 45231
Number of combinational cells: 38904
Number of sequential cells: 6327
Number of buf/inv: 4102
Number of references: 87
Combinational area: 98214.721680
Buf/Inv area: 6120.330000
Noncombinational area: 30241.606445
Total cell area: 128456.328125
Warning: Design 'axi_dma' has 3 unmapped cells. (OPT-170)
Warning: Latch inferred for signal 'ctrl/state_next'. (ELAB-350)
Information: Timing loop detected. (OPT-150)from pathlib import Path
lines = Path("synth.rpt").read_text(encoding="utf-8").splitlines()
counts = [l for l in lines if l.startswith("Number of")]
areas = [l for l in lines if "area:" in l]
warnings = [l for l in lines if l.startswith("Warning:")]
print("lines :", len(lines))
print("count rows:", len(counts))
print("area rows :", len(areas))
print("warnings :", len(warnings))
print()
print("the shape is:", "label, whitespace, number — one per line")
print("no sections :", "unlike the timing report's per-path records")lines : 22
count rows: 7
area rows : 4
warnings : 2
the shape is: label, whitespace, number — one per line
no sections : unlike the timing report's per-path recordsNo stateful parsing needed, unlike Chapter 28.1's multi-line path records. Every fact is one line: a label, a run of whitespace, a number. That simplicity is exactly why the parsing gets less attention than it deserves.
2. The Annotation
re.search(r"Total cell area:\s+([\d.]+)", line)import re
PATTERN = re.compile(r"Total cell area:\s+([\d.]+)")
SPELLINGS = [
"Total cell area: 128456.3281",
"Total cell area: 1,284,563.33",
"Total cell area: 1.2846e+05",
"Total cell area: 0",
"Total cell area: -1.5",
"Total cell area: 128456.3281",
]
for line in SPELLINGS:
m = PATTERN.search(line)
print(f"{line.split(':')[1].strip():<16} -> {m.group(1) if m else None}")128456.3281 -> 128456.3281
1,284,563.33 -> 1
1.2846e+05 -> 1.2846
0 -> 0
-1.5 -> None
128456.3281 -> 128456.3281Four of six correct and the two failures are different kinds of bad.
-1.5 returns None, which is the good failure: something downstream will notice a missing value, and a negative area is nonsense anyway.
1,284,563.33 returns "1". That is the bad one, and it is worth being precise about how bad:
import re
PATTERN = re.compile(r"Total cell area:\s+([\d.]+)")
LINE = "Total cell area: 1,284,563.33"
m = PATTERN.search(LINE)
captured = m.group(1)
print("the line says :", LINE.split(":")[1].strip())
print("the regex captures:", repr(captured))
print("float() accepts it:", float(captured))
print("an exception :", "no")
print("a None :", "no")
print()
true_value = 1284563.33
print("true area :", true_value)
print("parsed area :", float(captured))
print("wrong by a factor of:", f"{true_value / float(captured):,.0f}")
print()
print("what Chapter 28.6 would see:", "area collapsed to 1.0 — a catastrophic improvement")the line says : 1,284,563.33
the regex captures: '1'
float() accepts it: 1.0
an exception : no
a None : no
true area : 1284563.33
parsed area : 1.0
wrong by a factor of: 1,284,563
what Chapter 28.6 would see: area collapsed to 1.0 — a catastrophic improvement[\d.]+ stops at the first comma and captures a single character. float("1") is perfectly happy, nothing raises, and the flow now believes the design is one square micron.
This is the characteristic failure of report parsing and it is worse than a crash in every way: a crash names the line, and this produces a number that flows into Chapter 28.5's comparison and Chapter 28.6's threshold as though it were real. An area that collapsed by six orders of magnitude would be reported as an improvement.
3. A Number Pattern That Holds
import re
NUMBER = r"([-+]?[\d,]*\.?\d+(?:[eE][-+]?\d+)?)"
PATTERN = re.compile(rf"Total cell area:\s*{NUMBER}")
SPELLINGS = [
"Total cell area: 128456.3281",
"Total cell area: 1,284,563.33",
"Total cell area: 1.2846e+05",
"Total cell area: 0",
"Total cell area: -1.5",
"Total cell area: 128456.3281",
]
def parse_number(text):
return float(text.replace(",", ""))
for line in SPELLINGS:
m = PATTERN.search(line)
raw = m.group(1) if m else None
print(f"{str(raw):<16} -> {parse_number(raw) if raw else None}")128456.3281 -> 128456.3281
1,284,563.33 -> 1284563.33
1.2846e+05 -> 128460.0
0 -> 0.0
-1.5 -> -1.5
128456.3281 -> 128456.3281Six of six, including the two the annotation lost.
Four pieces of that pattern each earn their place.
[-+]? admits a sign. Area should never be negative and a delta often is, and the same helper parses both.
[\d,]* admits thousands separators, and .replace(",", "") removes them before float. Stripping the comma inside the regex is not possible; it has to be a second step, which is why parsing is a function rather than a pattern.
\.?\d+ requires at least one digit after any decimal point, so a stray . does not match on its own.
(?:[eE][-+]?\d+)? admits scientific notation, which tools use for very large or very small values and which is where the annotation lost five orders of magnitude.
\s* rather than \s+ tolerates a report with no padding — the last specimen line — which happens when a tool is run with a different formatting switch.
4. Extracting the Whole Report
Report : area
Design : axi_dma
Library: tcbn05_base_lvt_ss0p675v125c
Number of ports: 1842
Number of nets: 48213
Number of cells: 45231
Number of combinational cells: 38904
Number of sequential cells: 6327
Number of buf/inv: 4102
Combinational area: 98214.721680
Buf/Inv area: 6120.330000
Noncombinational area: 30241.606445
Total cell area: 128456.328125import re
from pathlib import Path
NUMBER = r"([-+]?[\d,]*\.?\d+(?:[eE][-+]?\d+)?)"
COUNTS = {
"ports": r"Number of ports:",
"nets": r"Number of nets:",
"cells": r"Number of cells:",
"combinational_cells": r"Number of combinational cells:",
"sequential_cells": r"Number of sequential cells:",
"buf_inv": r"Number of buf/inv:",
}
AREAS = {
"combinational_area": r"Combinational area:",
"buf_inv_area": r"Buf/Inv area:",
"noncombinational_area": r"Noncombinational area:",
"total_area": r"Total cell area:",
}
META = {"design": r"^Design\s*:\s*(\S+)", "library": r"^Library\s*:\s*(\S+)"}
def to_float(text):
return float(text.replace(",", ""))
def to_int(text):
value = to_float(text)
if value != int(value):
raise ValueError(f"expected an integer count, got {text!r}")
return int(value)
def parse_synthesis(path):
text = Path(path).read_text(encoding="utf-8", errors="replace")
out = {}
for name, pattern in META.items():
m = re.search(pattern, text, re.M)
out[name] = m.group(1) if m else None
for name, label in COUNTS.items():
m = re.search(rf"{label}\s*{NUMBER}", text)
out[name] = to_int(m.group(1)) if m else None
for name, label in AREAS.items():
m = re.search(rf"{re.escape(label)}\s*{NUMBER}", text)
out[name] = to_float(m.group(1)) if m else None
return out
data = parse_synthesis("synth.rpt")
for key in ("design", "library", "cells", "sequential_cells", "total_area"):
print(f"{key:<22} {data[key]}")
print()
missing = sorted(k for k, v in data.items() if v is None)
print("fields parsed :", len(data) - len(missing), "of", len(data))
print("missing :", missing or "none")
parts = data["combinational_area"] + data["noncombinational_area"]
print("parts sum to :", round(parts, 3))
print("total says :", data["total_area"])
print("they agree :", abs(parts - data["total_area"]) < 1e-6)design axi_dma
library tcbn05_base_lvt_ss0p675v125c
cells 45231
sequential_cells 6327
total_area 128456.328125
fields parsed : 12 of 12
missing : none
parts sum to : 128456.328
total says : 128456.328125
they agree : TrueFour decisions, and the second is the one that catches a tool change.
to_int refuses a non-integral count. A tool that prints 45231.0 for consistency with the area column beside it would pass a float-shaped pattern and then fail at int() — Section 6's hazard — and this converts it into a message naming the value.
Every field's presence is checked, so a regex that stops matching after a tool update is visible as a missing list rather than as a None three functions later. Chapter 28.1 Section 2 made the same argument for timing paths.
The parts are summed and compared against the total. Combinational plus non-combinational should equal the total, and a mismatch means a mis-parse — the same internal cross-check as Chapter 28.1's required - arrival.
The library is captured, because Section 7 is about to make it the thing that decides whether any of these numbers are comparable at all.
5. Warnings Worth Escalating
import re
from collections import Counter
WARNINGS = [
"Warning: Design 'axi_dma' has 3 unmapped cells. (OPT-170)",
"Warning: Latch inferred for signal 'ctrl/state_next'. (ELAB-350)",
"Warning: Latch inferred for signal 'ctrl/busy_next'. (ELAB-350)",
"Warning: Signal 'dbg_unused' is unconnected. (LINT-1)",
"Warning: Timing loop detected in path through 'u_alu'. (OPT-150)",
"Information: Area optimisation complete. (OPT-999)",
]
ESCALATE = {"ELAB-350": "a latch where a flop was intended",
"OPT-170": "cells that did not map to the library",
"OPT-150": "a combinational loop"}
IGNORE = {"LINT-1"}
CODE = re.compile(r"\((\w+-\d+)\)\s*$")
def triage(line):
if not line.startswith("Warning:"):
return None, "not a warning"
m = CODE.search(line)
code = m.group(1) if m else None
if code in ESCALATE:
return code, "ESCALATE"
if code in IGNORE:
return code, "ignore"
return code, "unknown — review once, then classify"
counts = Counter()
for line in WARNINGS:
code, verdict = triage(line)
counts[verdict] += 1
print(f"{str(code):<10} {verdict:<34} {line[9:52]}")
print()
print("verdicts:", dict(sorted(counts.items())))
print()
print("escalated by code, not by text:", True)
print("why :", "the text contains the signal name and varies per line")OPT-170 ESCALATE Design 'axi_dma' has 3 unmapped cells. (OPT
ELAB-350 ESCALATE Latch inferred for signal 'ctrl/state_next'
ELAB-350 ESCALATE Latch inferred for signal 'ctrl/busy_next'.
LINT-1 ignore Signal 'dbg_unused' is unconnected. (LINT-1
OPT-150 ESCALATE Timing loop detected in path through 'u_alu
None not a warning on: Area optimisation complete. (OPT-999)
verdicts: {'ESCALATE': 4, 'ignore': 1, 'not a warning': 1}
escalated by code, not by text: True
why : the text contains the signal name and varies per lineClassify on the code, never on the message text. ELAB-350 is stable; "Latch inferred for signal 'ctrl/state_next'" contains a signal name and is therefore a different string for every occurrence — which is Chapter 26.7 Section 5's normalisation rule arriving in a report rather than a log.
Three properties of the triage are worth copying.
An unknown code is neither escalated nor ignored, it is unknown. A new tool version introduces codes, and a classifier with only two buckets puts them in the wrong one — silently, and in whichever direction its default happens to be.
Information: lines are excluded by prefix, not by code. The last row's OPT-999 looks like an OPT code and is not a warning at all.
The two latch warnings share a code and are two findings. Chapter 26.7's bucketing applies: group by code for the summary, keep the individual lines for the detail, and ELAB-350 x2 is the line somebody acts on.
6. A Count Is Not a Float
VALUES = ["45231", "45231.0", "45,231", "45231.5"]
for text in VALUES:
cleaned = text.replace(",", "")
try:
direct = int(cleaned)
except ValueError as exc:
direct = f"ValueError: {exc}"
via_float = float(cleaned)
integral = via_float == int(via_float)
print(f"{text:<10} int() -> {str(direct):<58} float -> {via_float:<10} integral {integral}")45231 int() -> 45231 float -> 45231.0 integral True
45231.0 int() -> ValueError: invalid literal for int() with base 10: '45231.0' float -> 45231.0 integral True
45,231 int() -> 45231 float -> 45231.0 integral True
45231.5 int() -> ValueError: invalid literal for int() with base 10: '45231.5' float -> 45231.5 integral Falseint("45231.0") raises. A tool that prints a trailing .0 on its counts — for column alignment with the areas beside them — breaks a parser that calls int() on the captured text.
The two failures above are not the same, and collapsing them is the mistake:
45231.0is an integer written as a float, and should parse to45231.45231.5is not a count at all, and should raise.
Going through float first and then checking integrality separates them, which is what to_int in Section 4 does. A bare int(text) rejects both; a bare int(float(text)) accepts both and silently truncates the second to 45231.
7. Which Numbers Compare
METRICS = [
("total_area", "same library AND same corner", "um^2", "the library's cells differ in size"),
("combinational_area", "same library", "um^2", "same reason"),
("cells", "same ungrouping and flattening", "count", "a hierarchy boundary changes the count"),
("sequential_cells", "almost always comparable", "count", "flops are flops"),
("buf_inv", "same optimisation effort", "count", "effort decides buffering"),
("gate_equivalents", "same NAND2 reference", "GE", "the divisor is library-specific"),
]
print(f"{'metric':<22} {'comparable when':<32} {'unit':<7} why")
for name, when, unit, why in METRICS:
print(f"{name:<22} {when:<32} {unit:<7} {why}")
print()
always = [n for n, w, _, _ in METRICS if w.startswith("almost always")]
print("comparable with no conditions:", always)
print("everything else needs :", "the two runs to agree on something")metric comparable when unit why
total_area same library AND same corner um^2 the library's cells differ in size
combinational_area same library um^2 same reason
cells same ungrouping and flattening count a hierarchy boundary changes the count
sequential_cells almost always comparable count flops are flops
buf_inv same optimisation effort count effort decides buffering
gate_equivalents same NAND2 reference GE the divisor is library-specific
comparable with no conditions: ['sequential_cells']
everything else needs : the two runs to agree on somethingOne of six metrics compares unconditionally.
That is the canonical purpose's second half and it matters more than the parsing. Chapter 28.5 puts two runs side by side and Chapter 28.6 turns a difference into a verdict — and both are meaningless if the runs used different libraries, corners or effort levels. Chapter 27.2 Section 6 made exactly this argument about a parallelisation baseline: a comparison across two different experiments measures nothing.
The defence is the same one:
BEFORE = {"library": "tcbn05_base_lvt_ss0p675v125c", "corner": "ss0p675v125c",
"effort": "high", "flatten": False, "total_area": 128456.3}
AFTER_OK = dict(BEFORE, total_area=131002.7)
AFTER_DRIFT = dict(BEFORE, total_area=96120.4, library="tcbn05_base_svt_tt0p75v25c",
corner="tt0p75v25c")
PINNED = ("library", "corner", "effort", "flatten")
def comparable(a, b):
differ = sorted(k for k in PINNED if a.get(k) != b.get(k))
return (not differ), differ
for label, other in (("same setup", AFTER_OK), ("different library", AFTER_DRIFT)):
ok, differ = comparable(BEFORE, other)
delta = other["total_area"] - BEFORE["total_area"]
print(f"{label:<19} comparable={str(ok):<6} area delta {delta:+10.1f} differing: {differ}")
print()
print("the second run's area looks 25% better:", True)
print("what actually changed :", "the library and the corner")
print("what the area delta measures :", "nothing")same setup comparable=True area delta +2546.4 differing: []
different library comparable=False area delta -32335.9 differing: ['corner', 'library']
the second run's area looks 25% better: True
what actually changed : the library and the corner
what the area delta measures : nothingA 25% area improvement that is entirely a library change. The number is real, the delta is arithmetic, and it says nothing about the design.
The pinned fields go in the record beside the metrics for the same reason Chapter 28.1 Section 3 recorded the report switches and Chapter 24.6 recorded the environment: a measurement is only interpretable alongside what produced it, and the cheapest way to enforce that is a comparability check that refuses rather than a convention nobody remembers.
8. The Record
import json
import re
from pathlib import Path
NUMBER = r"([-+]?[\d,]*\.?\d+(?:[eE][-+]?\d+)?)"
def to_float(t):
return float(t.replace(",", ""))
def to_int(t):
v = to_float(t)
if v != int(v):
raise ValueError(f"expected an integer count, got {t!r}")
return int(v)
def extract(text, label, convert):
m = re.search(rf"{re.escape(label)}\s*{NUMBER}", text)
return convert(m.group(1)) if m else None
REPORT = """Design : axi_dma
Library: tcbn05_base_lvt_ss0p675v125c
Number of cells: 45231
Number of sequential cells: 6327
Combinational area: 98214.721680
Noncombinational area: 30241.606445
Total cell area: 128456.328125
Warning: Latch inferred for signal 'ctrl/state_next'. (ELAB-350)
"""
def synthesis_record(text, *, corner, effort, flatten):
design = re.search(r"^Design\s*:\s*(\S+)", text, re.M)
library = re.search(r"^Library\s*:\s*(\S+)", text, re.M)
codes = re.findall(r"^Warning:.*\((\w+-\d+)\)\s*$", text, re.M)
metrics = {
"cells": extract(text, "Number of cells:", to_int),
"sequential_cells": extract(text, "Number of sequential cells:", to_int),
"combinational_area": extract(text, "Combinational area:", to_float),
"noncombinational_area": extract(text, "Noncombinational area:", to_float),
"total_area": extract(text, "Total cell area:", to_float),
}
problems = [k for k, v in metrics.items() if v is None]
parts = (metrics["combinational_area"] or 0) + (metrics["noncombinational_area"] or 0)
if metrics["total_area"] and abs(parts - metrics["total_area"]) > 1e-6:
problems.append(f"areas do not sum: {parts} != {metrics['total_area']}")
return {"design": design.group(1) if design else None,
"context": {"library": library.group(1) if library else None,
"corner": corner, "effort": effort, "flatten": flatten},
"metrics": metrics,
"warning_codes": sorted(set(codes)),
"problems": problems}
record = synthesis_record(REPORT, corner="ss0p675v125c", effort="high", flatten=False)
print(json.dumps(record, indent=2, sort_keys=True))
print()
print("problems:", record["problems"] or "none")
print("comparable against another run only if its context matches:",
sorted(record["context"])){
"context": {
"corner": "ss0p675v125c",
"effort": "high",
"flatten": false,
"library": "tcbn05_base_lvt_ss0p675v125c"
},
"design": "axi_dma",
"metrics": {
"cells": 45231,
"combinational_area": 98214.72168,
"noncombinational_area": 30241.606445,
"sequential_cells": 6327,
"total_area": 128456.328125
},
"problems": [],
"warning_codes": [
"ELAB-350"
]
}
problems: none
comparable against another run only if its context matches: ['corner', 'effort', 'flatten', 'library']Five decisions, and the first is the structural one.
context and metrics are separate objects. Chapter 28.5 compares metrics and checks context for equality first — two different operations on two different parts of the record, which a flat dictionary would blur.
problems is a list, not an exception. A report with a missing field still produces a record, which Chapter 26.6's reconciliation needs; the problems travel with it rather than replacing it.
The area cross-check is one of the problems, so a mis-parse shows up in the same place as a missing field.
Warning codes are deduplicated and sorted, giving a stable set for Chapter 26.7-style comparison between runs; the individual lines belong in the detail report rather than the record.
Context fields the report does not state are passed in. corner, effort and flatten come from the flow that invoked the tool, because most reports do not echo them — which makes them exactly the fields most likely to differ unnoticed.
9. Predict, Run, Observe
import re
OLD = re.compile(r"Total cell area:\s+([\d.]+)")
NEW = re.compile(r"Total cell area:\s*([-+]?[\d,]*\.?\d+(?:[eE][-+]?\d+)?)")
L1 = "Total cell area: 128456.33"
L2 = "Total cell area: 1,284,563.33"
L3 = "Total cell area: 1.2846e+05"
# A
print("A:", OLD.search(L1).group(1))
# B
print("B:", OLD.search(L2).group(1))
# C
print("C:", float(OLD.search(L2).group(1)))
# D
print("D:", NEW.search(L2).group(1))
# E
print("E:", float(NEW.search(L2).group(1).replace(",", "")))
# F
print("F:", float(NEW.search(L3).group(1)))
# G
try:
int("45231.0")
except ValueError:
print("G: ValueError")
# H
print("H:", int(float("45231.0")), float("45231.5") == int(float("45231.5")))The verified output:
A: 128456.33
B: 1
C: 1.0
D: 1,284,563.33
E: 1284563.33
F: 128460.0
G: ValueError
H: 45231 FalseA — the annotation works on the plain spelling.
B — and captures one character from the comma spelling.
C — which float accepts without complaint.
D — the tolerant pattern captures the whole thing.
E — and stripping the comma gives the real value.
F — scientific notation parses too, where the annotation lost five orders of magnitude.
G — int() on a float-shaped count raises.
H — going through float first works, and the integrality test is what separates a formatted count from a genuinely fractional value.
10. Exercises
Exercise 1 — the shape. Say why a synthesis report needs no stateful parsing and a timing report does.
Exercise 2 — six spellings. Run the annotation's regex over all six and report which fail.
Exercise 3 — the bad failure. Explain why None is a better outcome than "1".
Exercise 4 — the factor. Compute how wrong the comma case is for your own design's area.
Exercise 5 — downstream. Describe what Chapter 28.6 would report given a parsed area of 1.0.
Exercise 6 — four pieces. For each part of the tolerant pattern, give a line it rescues.
Exercise 7 — why two steps. Say why the comma cannot be stripped inside the regex.
Exercise 8 — the locale. Construct a European-format number and show the tolerant pattern mis-parsing it.
Exercise 9 — the plausibility bound. Implement one from your previous run's area.
Exercise 10 — presence checks. Add a missing list and break a label to see it fire.
Exercise 11 — the internal sum. Add the combinational-plus-noncombinational check and construct a mis-parse it catches.
Exercise 12 — triage by code. Classify your own tool's warning codes into escalate, ignore and unknown.
Exercise 13 — why not the text. Say what makes the message text unsuitable as a key, citing Chapter 26.7.
Exercise 14 — the third bucket. Explain what a two-bucket classifier does to a code from a new tool version.
Exercise 15 — Information lines. Find one whose code looks like a warning code.
Exercise 16 — two kinds of non-integer. Distinguish 45231.0 from 45231.5 and handle each correctly.
Exercise 17 — one of six. List your own metrics and say which compares unconditionally.
Exercise 18 — the free improvement. Construct two runs whose area differs only because of the library.
Exercise 19 — pin the context. Implement the comparability check and say which fields it must cover for your flow.
Exercise 20 — not in the report. Name the context fields your tool does not echo, and say where they come from.
11. Summary
- A synthesis report is one fact per line, which is why its parsing gets less scrutiny than it deserves.
[\d.]+captures"1"from a comma-separated number — not an exception, notNone, a plausible value wrong by a factor of 1,284,563.- A wrong area is harder to notice than a wrong slack, because any number is plausible.
- A tolerant pattern admits a sign, thousands separators, a decimal point and an exponent, and the comma is stripped in a second step.
- Use
\s*rather than\s+, so an unpadded report still matches. - A locale that swaps comma and point defeats any regex — the defence is a plausibility bound, not a cleverer pattern.
- Check that every expected field was found, and report the missing ones as a list.
- Cross-check the parts against the total; combinational plus non-combinational must equal the total.
- Triage warnings by code, never by message text, which contains signal names and varies per line.
- Keep a third bucket for unknown codes, or a new tool version's codes land in whichever default you chose.
int("45231.0")raises, and a tool may print a trailing.0for column alignment.- Separate an integer written as a float from a genuinely fractional value by going through
floatand testing integrality. - Only one of six synthesis metrics compares unconditionally — area needs the same library and corner, counts need the same ungrouping, gate equivalents need the same reference.
- A 25% area improvement can be entirely a library change, and the delta then measures nothing.
- Keep
contextandmetricsas separate objects, and check the context for equality before comparing the metrics. - Pass in the context fields the report does not echo — corner, effort and flattening are the ones most likely to differ unnoticed.
The next chapter applies the same techniques to place-and-route output, where the numbers are percentages and the sections are back.
Where this fits
Part of the Python curriculum.
