Coverage for apio/common/build_report.py: 95%
65 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-07 05:08 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-07 05:08 +0000
1# This file is part of the Apio project
2# (C) 2016-present FPGAwars
3# Project authors:
4# * Jesús Arroyo (Jesus89) (2016-2019)
5# * Juan Gonzalez (obijuan) (2019-2024)
6# * The Apio Team (2024-present)
7# License: GPLv2
9"""Utilities related to the build report file hardware.pnr."""
11import json
12from typing import Any
13from dataclasses import dataclass
14from pathlib import Path
15from apio.common.apio_console import fatal_error
16from apio.common.proto.stubs.apio_common_pb2 import ApioArch
19@dataclass(frozen=True)
20class ResourceReport:
21 """Represents the info of a single FPGA resource."""
23 name: str
24 available: int
25 used: int
26 # -- None if the percentage can't be computed or is outside of
27 # -- the range [0, 100], which indicates invalid nextpnr data.
28 percentage: float | None
31@dataclass(frozen=True)
32class ClockReport:
33 """Represents the info of a single clock signal."""
35 name: str
36 fmax_mhz: float
39@dataclass(frozen=True)
40class BuildReport:
41 """Represents FPGA resources utilization and clocks speeds."""
43 arch: ApioArch
44 resources: list[ResourceReport]
45 clocks: list[ClockReport]
48def _identify_build_architecture(json_dict: dict[str, Any]) -> ApioArch:
49 """Given the content of the nextpnr json output file hardware.pnr,
50 identify the architecture of the build."""
52 # -- A set of patterns and their matching architectures.
53 patterns = {
54 "ICESTORM": ApioArch.ice40,
55 "TRELLIS": ApioArch.ecp5,
56 "IOLOGICI": ApioArch.gowin,
57 "SLICE": ApioArch.xilinx,
58 }
60 # -- Collect all archs whose patterns match.
61 matches: list[ApioArch] = []
62 for pattern, arch in patterns.items():
63 for key in json_dict["utilization"]:
64 if pattern in key:
65 matches.append(arch)
66 break
68 # -- Error if we got none or more than 1.
69 if len(matches) != 1: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 fatal_error(f"Expected exactly 1 arch match, found: {matches}")
72 # -- Found it.
73 return matches[0]
76def _parse_clk_net_name(clk_net: str, arch: ApioArch) -> str:
77 """Given a clock net as it appears in the nextpnr file hardware.pnr,
78 extracts and returns a user friendly clock name. The parsing of the
79 clock net depends ont the fpga architecture of the build."""
81 # -- Break the clk net name into parts
82 name_parts = clk_net.split("$")
84 # -- Handle ICE40
85 if arch == ApioArch.ice40:
86 return name_parts[0].rstrip("_")
88 # -- Handle ECP5
89 if arch == ApioArch.ecp5:
90 return name_parts[2]
92 # -- Handle Gowin
93 if arch == ApioArch.gowin:
94 return name_parts[0].removesuffix("_IBUF_I_O")
96 # -- Handle Xilinx
97 if arch == ApioArch.xilinx: 97 ↛ 104line 97 didn't jump to line 104 because the condition on line 97 was always true
98 return next(
99 (part for part in reversed(name_parts) if part),
100 "",
101 )
103 # -- Handle unknown architecture.
104 fatal_error(f"Unexpected FPGA architecture: {arch}")
107def _compute_percentage(used: int, available: int) -> float | None:
108 """Returns the utilization percentage of a resource, or None if
109 it can't be computed or is outside of the range [0, 100]."""
110 if available <= 0 or not 0 <= used <= available:
111 return None
112 return 100 * used / available
115def read_build_report(pnr_json_file_path: Path) -> BuildReport:
116 """Read the given hardware.pnr file, parse it, and return
117 a summary in the form of a BuildReport object. Fatal error on any
118 error. The resources and the clocks in the result are sorted
119 alphabetically by name, case insensitive"""
121 # pylint: disable=too-many-locals
122 # pylint: disable=broad-exception-caught
124 # -- Sanity checks
125 assert isinstance(pnr_json_file_path, Path), type(pnr_json_file_path)
126 assert pnr_json_file_path.name == "hardware.pnr", pnr_json_file_path
128 # -- Read the json text from the file
129 try:
130 json_text = pnr_json_file_path.read_text(encoding="utf-8")
131 except Exception as e:
132 fatal_error(
133 f"Failed to read {str(pnr_json_file_path)}",
134 cause=e,
135 info="Did you build successfully this project env?",
136 )
138 # -- Parse the json text into a dict.
139 try:
140 json_dict = json.loads(json_text)
141 except Exception as e:
142 fatal_error(
143 f"Failed parsing json file: {str(pnr_json_file_path)}", cause=e
144 )
146 # -- Identify the FPGA arch of the build.
147 arch = _identify_build_architecture(json_dict)
149 # -- Collect resources
150 resources: list[ResourceReport] = []
151 for resource_name, vals in json_dict["utilization"].items():
152 available: int = vals["available"]
153 used: int = vals["used"]
154 percentage = _compute_percentage(used, available)
155 resources.append(
156 ResourceReport(resource_name, available, used, percentage)
157 )
159 # -- Sort resources alphabetically, case insensitive.
160 resources.sort(key=lambda r: r.name.lower())
162 # -- Collect clocks
163 clocks: list[ClockReport] = []
164 for clk_net, vals in json_dict["fmax"].items():
165 # -- Extract a user friendly clock name.
166 name = _parse_clk_net_name(clk_net, arch)
168 # -- Extract max speed
169 fmax_mhz = vals["achieved"]
171 # -- Append to clock list.
172 clocks.append(ClockReport(name, fmax_mhz))
174 # -- Sort clocks alphabetically, case insensitive.
175 clocks.sort(key=lambda r: r.name.lower())
177 result = BuildReport(arch, resources, clocks)
178 return result