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
« 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#
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
20__all__ = ["TimeSeriesPlotResult", "TimeSeriesPlotter"]
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)
29 @property
30 def is_error(self) -> bool:
31 return len(self.errors) > 0
33 @property
34 def error_str(self) -> str:
35 if not self.is_error:
36 return "No errors."
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}"
46 return msg
48 @property
49 def has_plots(self) -> bool:
50 return len(self.plots) > 0
52 def reset(self) -> None:
53 self.errors.clear()
54 self.warnings.clear()
55 self.plots.clear()
58class TimeSeriesPlotter:
59 __slots__ = (
60 "_data",
61 "_plot_title",
62 "_result",
63 "_x_label",
64 "_y_label",
65 )
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()
80 def add_error(self, msg: str) -> None:
81 LOG().error(msg)
82 self._result.errors.insert(0, msg)
84 def add_warning(self, msg: str) -> None:
85 LOG().warning(msg)
86 self._result.warnings.insert(0, msg)
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.")
93 return self._result
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
102 if IS_DEBUG:
103 LOG().debug(f"Creating TimeSeriesPlot for {self._plot_title}")
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}:"
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
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)
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)
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)
142 return self._result
144 def _create_plot(
145 self,
146 plot_title: str,
147 plot_info: TimeSeriesPlotInfo,
148 ) -> EmailImage | None:
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 )
160 if IS_TRACE:
161 LOG().trace(f"Creating TimeSeriesPlot with info:\n{plot_info.debug_str}\n")
163 plot = TimeSeriesPlot(
164 title=f"{plot_title} - {plot_info.label}",
165 info=plot_info,
166 )
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