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

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 

8 

9"""Utilities related to the build report file hardware.pnr.""" 

10 

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 

17 

18 

19@dataclass(frozen=True) 

20class ResourceReport: 

21 """Represents the info of a single FPGA resource.""" 

22 

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 

29 

30 

31@dataclass(frozen=True) 

32class ClockReport: 

33 """Represents the info of a single clock signal.""" 

34 

35 name: str 

36 fmax_mhz: float 

37 

38 

39@dataclass(frozen=True) 

40class BuildReport: 

41 """Represents FPGA resources utilization and clocks speeds.""" 

42 

43 arch: ApioArch 

44 resources: list[ResourceReport] 

45 clocks: list[ClockReport] 

46 

47 

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

51 

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 } 

59 

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 

67 

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}") 

71 

72 # -- Found it. 

73 return matches[0] 

74 

75 

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

80 

81 # -- Break the clk net name into parts 

82 name_parts = clk_net.split("$") 

83 

84 # -- Handle ICE40 

85 if arch == ApioArch.ice40: 

86 return name_parts[0].rstrip("_") 

87 

88 # -- Handle ECP5 

89 if arch == ApioArch.ecp5: 

90 return name_parts[2] 

91 

92 # -- Handle Gowin 

93 if arch == ApioArch.gowin: 

94 return name_parts[0].removesuffix("_IBUF_I_O") 

95 

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 ) 

102 

103 # -- Handle unknown architecture. 

104 fatal_error(f"Unexpected FPGA architecture: {arch}") 

105 

106 

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 

113 

114 

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""" 

120 

121 # pylint: disable=too-many-locals 

122 # pylint: disable=broad-exception-caught 

123 

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 

127 

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 ) 

137 

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 ) 

145 

146 # -- Identify the FPGA arch of the build. 

147 arch = _identify_build_architecture(json_dict) 

148 

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 ) 

158 

159 # -- Sort resources alphabetically, case insensitive. 

160 resources.sort(key=lambda r: r.name.lower()) 

161 

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) 

167 

168 # -- Extract max speed 

169 fmax_mhz = vals["achieved"] 

170 

171 # -- Append to clock list. 

172 clocks.append(ClockReport(name, fmax_mhz)) 

173 

174 # -- Sort clocks alphabetically, case insensitive. 

175 clocks.sort(key=lambda r: r.name.lower()) 

176 

177 result = BuildReport(arch, resources, clocks) 

178 return result