Coverage for sparkle/platform/latex.py: 91%

79 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-05 14:48 +0000

1#!/usr/bin/env python3 

2# -*- coding: UTF-8 -*- 

3"""Helper classes/method for LaTeX and bibTeX.""" 

4from shutil import which 

5from pathlib import Path 

6import subprocess 

7from enum import Enum 

8 

9import numpy as np 

10import pandas as pd 

11import plotly.express as px 

12import plotly.io as pio 

13 

14pio.kaleido.scope.mathjax = None # Bug fix for kaleido 

15 

16 

17class ReportType(str, Enum): 

18 """enum for separating different types of reports.""" 

19 ALGORITHM_SELECTION = "algorithm_selection" 

20 ALGORITHM_CONFIGURATION = "algorithm_configuration" 

21 PARALLEL_PORTFOLIO = "parallel_portfolio" 

22 

23 

24def check_tex_commands_exist(latex_directory_path: Path) -> None: 

25 """Raise an exception if one of the latex commands is not present.""" 

26 if which("bibtex") is None or which("pdflatex") is None: 

27 raise Exception("Error: It seems like latex is not available on your system.\n" 

28 "You can install latex and run the command again, " 

29 f"or copy the source files in {latex_directory_path} on your " 

30 "local machine to generate the report.") 

31 

32 

33def underscore_for_latex(string: str) -> str: 

34 """Return the input str with the underscores escaped for use in LaTeX. 

35 

36 Args: 

37 string: A given str with underscores. 

38 

39 Returns: 

40 The corresponding str with underscores escaped. 

41 """ 

42 return string.replace("_", "\\_") 

43 

44 

45def list_to_latex(content: list | list[tuple]) -> str: 

46 """Convert a list to LaTeX. 

47 

48 Args: 

49 content: The list to convert. If a tuple, first item will be boldface. 

50 

51 Returns: 

52 The list as LaTeX str. 

53 """ 

54 if len(content) == 0: 

55 return "\\item" 

56 if isinstance(content[0], tuple): 

57 return "".join(f"\\item \\textbf{{{item[0]}}}{item[1]}" for item in content) 

58 return "".join(f"\\item {item}\n" for item in content) 

59 

60 

61def generate_comparison_plot(points: list, 

62 figure_filename: str, 

63 xlabel: str = "default", 

64 ylabel: str = "optimised", 

65 title: str = "", 

66 scale: str = "log", 

67 limit: str = "magnitude", 

68 limit_min: float = 0.2, 

69 limit_max: float = 0.2, 

70 replace_zeros: bool = True, 

71 magnitude_lines: int = 2147483647, 

72 output_dir: Path = None) -> None: 

73 """Create comparison plots between two different solvers/portfolios. 

74 

75 Args: 

76 points: list of points which represents with the performance results of 

77 (solverA, solverB) 

78 figure_filename: filename without filetype (e.g., .jpg) to save the figure to. 

79 xlabel: Name of solverA (default: default) 

80 ylabel: Name of solverB (default: optimised) 

81 title: Display title in the image (default: None) 

82 scale: [linear, log] (default: linear) 

83 limit: The method to compute the axis limits in the figure 

84 [absolute, relative, magnitude] (default: relative) 

85 absolute: Uses the limit_min/max values as absolute values 

86 relative: Decreases/increases relatively to the min/max values found in the 

87 points. E.g., min/limit_min and max*limit_max 

88 magnitude: Increases the order of magnitude(10) of the min/max values in the 

89 points. E.g., 10**floor(log10(min)-limit_min) 

90 and 10**ceil(log10(max)+limit_max) 

91 limit_min: Value used to compute the minimum limit 

92 limit_max: Value used to compute the maximum limit 

93 computing the figure limits. This is only relevant for runtime objectives 

94 replace_zeros: Replaces zeros valued performances to a very small value to make 

95 plotting on log-scale possible 

96 magnitude_lines: Draw magnitude lines (only supported for log scale) 

97 output_dir: directory path to place the figure and its intermediate files in 

98 (default: current working directory) 

99 """ 

100 output_dir = Path() if output_dir is None else Path(output_dir) 

101 

102 df = pd.DataFrame(points, columns=[xlabel, ylabel]) 

103 if replace_zeros and (df < 0).any(axis=None): 

104 # Log scale cannot deal with negative and zero values, set to smallest non zero 

105 df[df < 0] = np.nextafter(0, 1) 

106 

107 # process range values 

108 min_point_value = df.min(numeric_only=True).min() 

109 max_point_value = df.max(numeric_only=True).max() 

110 

111 if limit == "absolute": 

112 min_value = limit_min 

113 max_value = limit_max 

114 elif limit == "relative": 

115 min_value = (min_point_value * (1 / limit_min) if min_point_value > 0 

116 else min_point_value * limit_min) 

117 max_value = (max_point_value * limit_max if max_point_value > 0 

118 else max_point_value * (1 / limit_max)) 

119 elif limit == "magnitude": 

120 min_value = 10 ** (np.floor(np.log10(min_point_value)) - limit_min) 

121 max_value = 10 ** (np.ceil(np.log10(max_point_value)) + limit_max) 

122 

123 if scale == "log" and np.min(points) <= 0: 

124 raise Exception("Cannot plot negative and zero values on a log scales") 

125 

126 output_plot = output_dir / f"{figure_filename}.pdf" 

127 log_scale = scale == "log" 

128 fig = px.scatter(data_frame=df, x=xlabel, y=ylabel, 

129 range_x=[min_value, max_value], range_y=[min_value, max_value], 

130 title=title, log_x=log_scale, log_y=log_scale, 

131 width=500, height=500) 

132 # Add in the seperation line 

133 fig.add_shape(type="line", x0=0, y0=0, x1=max_value, y1=max_value, 

134 line=dict(color="lightgrey", width=1)) 

135 fig.update_traces(marker=dict(color="RoyalBlue", symbol="x")) 

136 fig.update_layout( 

137 plot_bgcolor="white" 

138 ) 

139 fig.update_xaxes( 

140 type="linear" if not log_scale else "log", 

141 mirror=True, 

142 tickmode="linear", 

143 ticks="outside", 

144 tick0=0, 

145 dtick=100 if not log_scale else 1, 

146 showline=True, 

147 linecolor="black", 

148 gridcolor="lightgrey" 

149 ) 

150 fig.update_yaxes( 

151 type="linear" if not log_scale else "log", 

152 mirror=True, 

153 tickmode="linear", 

154 ticks="outside", 

155 tick0=0, 

156 dtick=100 if not log_scale else 1, 

157 showline=True, 

158 linecolor="black", 

159 gridcolor="lightgrey" 

160 ) 

161 fig.write_image(output_plot) 

162 

163 

164def fill_template_tex(template_tex: str, variables: dict) -> str: 

165 """Given a latex template, replaces all the @@ variables using the dict. 

166 

167 Args: 

168 template_tex: The template to be populated 

169 variables: Variable names (key) with their target (value) 

170 

171 Returns: 

172 The populated latex string. 

173 """ 

174 for variable_key, target_value in variables.items(): 

175 variable = f"@@{variable_key}@@" 

176 target_value = str(target_value) 

177 # We don't modify variable names in the Latex file 

178 if "\\includegraphics" not in target_value and "\\label" not in target_value: 

179 # Rectify underscores in target_value 

180 target_value = target_value.replace("_", r"\textunderscore ") 

181 template_tex = template_tex.replace(variable, target_value) 

182 return template_tex 

183 

184 

185def compile_pdf(latex_files_path: Path, latex_report_filename: Path) -> Path: 

186 """Compile the given latex files to a PDF. 

187 

188 Args: 

189 latex_files_path: Path to the directory with source files 

190 where the report will be generated. 

191 latex_report_filename: Name of the output files. 

192 

193 Returns: 

194 Path to the newly generated report in PDF format. 

195 """ 

196 pdf_process = subprocess.run(["pdflatex", "-interaction=nonstopmode", 

197 f"{latex_report_filename}.tex"], 

198 cwd=latex_files_path, capture_output=True) 

199 

200 if pdf_process.returncode != 0: 

201 print(f"[{pdf_process.returncode}] ERROR generating with PDFLatex command:\n" 

202 f"{pdf_process.stdout.decode()}\n {pdf_process.stderr.decode()}\n") 

203 

204 bibtex_process = subprocess.run(["bibtex", f"{latex_report_filename}.aux"], 

205 cwd=latex_files_path, capture_output=True) 

206 

207 if bibtex_process.returncode != 0: 

208 print("ERROR whilst generating with Bibtex command:" 

209 f"{bibtex_process.stdout} {bibtex_process.stderr}") 

210 

211 # TODO: Fix compilation for references 

212 # (~\ref[] yields [?] in pdf, re-running command fixes it) 

213 # We have to re-run the same pdf command to take in the updates bib files from bibtex 

214 # But Bibtex cannot function without .aux file produced by pdflatex. Hence run twice. 

215 pdf_process = subprocess.run(["pdflatex", "-interaction=nonstopmode", 

216 f"{latex_report_filename}.tex"], 

217 cwd=latex_files_path, capture_output=True) 

218 

219 return Path(latex_files_path / latex_report_filename).with_suffix(".pdf") 

220 

221 

222def generate_report(latex_source_path: Path, 

223 latex_template_name: str, 

224 target_path: Path, 

225 report_name: str, 

226 variable_dict: dict) -> None: 

227 """General steps to generate a report. 

228 

229 Args: 

230 latex_source_path: The path to the template 

231 latex_template_name: The template name 

232 target_path: The directory where the result should be placed 

233 report_name: The name of the pdf (without suffix) 

234 variable_dict: TBD 

235 """ 

236 latex_template_filepath = latex_source_path / latex_template_name 

237 

238 report_content = latex_template_filepath.open("r").read() 

239 report_content = fill_template_tex(report_content, variable_dict) 

240 

241 target_path.mkdir(parents=True, exist_ok=True) 

242 latex_report_filepath = target_path / report_name 

243 latex_report_filepath = latex_report_filepath.with_suffix(".tex") 

244 Path(latex_report_filepath).open("w+").write(report_content) 

245 

246 check_tex_commands_exist(target_path) 

247 report_path = compile_pdf(target_path, report_name) 

248 

249 print(f"Report is placed at: {report_path}")