Coverage for functions \ flipdare \ analysis \ plot \ time_series_plotter.py: 79%

101 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13from dataclasses import dataclass, field 

14from flipdare.analysis.data._time_series_protocol import TimeSeriesProtocol, TimeSeriesPlotInfo 

15from flipdare.analysis.plot.time_series_plot import TimeSeriesPlot 

16from flipdare.constants import IS_DEBUG, IS_TRACE 

17from flipdare.mailer.email_image import EmailImage 

18from flipdare.app_log import LOG 

19 

20__all__ = ["TimeSeriesPlotResult", "TimeSeriesPlotter"] 

21 

22 

23@dataclass 

24class TimeSeriesPlotResult: 

25 errors: list[str] = field(default_factory=list) 

26 warnings: list[str] = field(default_factory=list) 

27 plots: list[EmailImage] = field(default_factory=list) 

28 

29 @property 

30 def is_error(self) -> bool: 

31 return len(self.errors) > 0 

32 

33 @property 

34 def error_str(self) -> str: 

35 if not self.is_error: 

36 return "No errors." 

37 

38 error_str = "\n".join(self.errors) 

39 warnings_str = "\n".join(self.warnings) 

40 msg = "" 

41 if error_str: 

42 msg += f"\nErrors:\n{error_str}" 

43 if warnings_str: 

44 msg += f"\nWarnings:\n{warnings_str}" 

45 

46 return msg 

47 

48 @property 

49 def has_plots(self) -> bool: 

50 return len(self.plots) > 0 

51 

52 def reset(self) -> None: 

53 self.errors.clear() 

54 self.warnings.clear() 

55 self.plots.clear() 

56 

57 

58class TimeSeriesPlotter: 

59 __slots__ = ( 

60 "_data", 

61 "_plot_title", 

62 "_result", 

63 "_x_label", 

64 "_y_label", 

65 ) 

66 

67 def __init__( 

68 self, 

69 plot_title: str, 

70 x_label: str, 

71 y_label: str, 

72 data: TimeSeriesProtocol, 

73 ) -> None: 

74 self._plot_title = plot_title 

75 self._x_label = x_label 

76 self._y_label = y_label 

77 self._data = data 

78 self._result: TimeSeriesPlotResult = TimeSeriesPlotResult() 

79 

80 def add_error(self, msg: str) -> None: 

81 LOG().error(msg) 

82 self._result.errors.insert(0, msg) 

83 

84 def add_warning(self, msg: str) -> None: 

85 LOG().warning(msg) 

86 self._result.warnings.insert(0, msg) 

87 

88 def create(self) -> TimeSeriesPlotResult: 

89 if self._result.has_plots: 

90 if IS_DEBUG: 

91 LOG().debug(f"Plot {self._plot_title} already created, returning existing result.") 

92 

93 return self._result 

94 

95 self._result.reset() 

96 plot_info = self._data.plot_info() 

97 if len(plot_info) == 0: 

98 msg = f"Plot {self._plot_title} has no data to plot." 

99 self.add_error(msg) 

100 return self._result 

101 

102 if IS_DEBUG: 

103 LOG().debug(f"Creating TimeSeriesPlot for {self._plot_title}") 

104 

105 skipped_ct = 0 

106 for idx, info in enumerate(plot_info): 

107 data = info.data 

108 debug_label = f"Plot {self._plot_title} index {idx}:" 

109 

110 if len(data) == 0: 

111 msg = f"{debug_label} No data, skipping plot generation." 

112 self.add_warning(msg) 

113 skipped_ct += 1 

114 continue 

115 if any(len(series) == 0 for series in data): 

116 msg = f"{debug_label} has empty series data, skipping plot generation." 

117 self.add_warning(msg) 

118 skipped_ct += 1 

119 continue 

120 

121 try: 

122 plot = self._create_plot( 

123 plot_title=self._plot_title, 

124 plot_info=info, 

125 ) 

126 if plot is not None: 

127 self._result.plots.append(plot) 

128 except Exception as ex: 

129 msg = f"{debug_label} failed to create: {ex}" 

130 self.add_error(msg) 

131 

132 images = self._result.plots 

133 if len(images) == 0: 

134 msg = f"Failed to create any plots for {self._plot_title}." 

135 self.add_error(msg) 

136 

137 if skipped_ct == len(plot_info): 

138 # No plots generated this is an error .. 

139 msg = f"All {skipped_ct} plots for {self._plot_title} were skipped due to missing/empty data." 

140 self.add_error(msg) 

141 

142 return self._result 

143 

144 def _create_plot( 

145 self, 

146 plot_title: str, 

147 plot_info: TimeSeriesPlotInfo, 

148 ) -> EmailImage | None: 

149 

150 try: 

151 plot_info = TimeSeriesPlotInfo( 

152 label=plot_info.label, 

153 x_title=plot_info.x_title, 

154 y_title=plot_info.y_title, 

155 x_labels=plot_info.x_labels, 

156 legend_labels=plot_info.legend_labels, 

157 data=plot_info.data, 

158 ) 

159 

160 if IS_TRACE: 

161 LOG().trace(f"Creating TimeSeriesPlot with info:\n{plot_info.debug_str}\n") 

162 

163 plot = TimeSeriesPlot( 

164 title=f"{plot_title} - {plot_info.label}", 

165 info=plot_info, 

166 ) 

167 

168 return EmailImage( 

169 plot.create(), 

170 plot.create_notes(), 

171 ) 

172 except Exception as ex: 

173 msg = f"Failed to create plot for {self._plot_title}: {ex}" 

174 self.add_error(msg) 

175 return None