File: plot.py

package info (click to toggle)
siril 1.4.0~rc2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 47,352 kB
  • sloc: ansic: 174,082; cpp: 28,254; python: 7,891; makefile: 974; xml: 777; sh: 271
file content (246 lines) | stat: -rw-r--r-- 9,937 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# Copyright (C) 2005-2011 Francois Meyer (dulle at free.fr)
# Copyright (C) 2012-2025 team free-astro (see more in AUTHORS file)
# Reference site is https://siril.org
# SPDX-License-Identifier: GPL-3.0-or-later

"""
Plot submodule for Siril, providing classes for plot data representation and serialization.
This submodule enables users to create and configure various types of plots with customizable
appearance and error bars.
"""

from typing import Union, Optional, List, Tuple
import struct
import numpy as np
from .enums import PlotType

class SeriesData:
    """
    Represents a single data series for plotting.

    Members:
        x_coords: Either a List[float] or a np.ndarray containing the values
        for the x coordinates for this series

        y_coords: Either a List[float] or a np.ndarray containing the values
        for the y coordinates for this series

        label: A str containing a label for the series (shown in the plot
        legend)

        plot_type: a PlotType setting the type of marks to use

        n_error: Either a List[float] or a np.ndarray containing values for
        the y-axis negative errors for this series

        p_error: Either a List[float] or a np.ndarray containing values for
        the y-axis positive errors for this series
    """

    def __init__(
        self,
        x_coords: Union[List[float], np.ndarray],
        y_coords: Union[List[float], np.ndarray],
        label: Optional[str] = None,
        plot_type: Optional[PlotType] = PlotType.LINES,
        n_error: Optional[Union[List[float], np.ndarray]] = None,
        p_error: Optional[Union[List[float], np.ndarray]] = None
    ):
        """
        Represents a single data series for plotting.

        Args:
            x_coords: X-coordinates of the data series
            y_coords: Y-coordinates of the data series
            label: Label for the series (optional)
            plot_type: Type of plot for this series (optional, default is LINES)
            n_error: Y-axis negative error for error bars (optional)
            p_error: Y-axis positive error for error bars (optional)
        """
        # Convert inputs to numpy arrays
        self.x_coords = np.asarray(x_coords, dtype=np.float32)
        self.y_coords = np.asarray(y_coords, dtype=np.float32)

        # Validate coordinate lengths
        if len(self.x_coords) != len(self.y_coords):
            raise ValueError("x and y coordinates must have the same length")

        # Set optional parameters
        self.label = label or "Data Series"
        self.plot_type = plot_type

        # Handle error bars
        self.n_error = np.asarray(n_error, dtype=np.float32) if n_error is not None else None
        self.p_error = np.asarray(p_error, dtype=np.float32) if p_error is not None else None

        # Validate error bar lengths if provided
        if self.n_error is not None and len(self.n_error) != len(self.y_coords):
            raise ValueError("n_error must have the same length as y_coords")
        if self.p_error is not None and len(self.p_error) != len(self.y_coords):
            raise ValueError("p_error must have the same length as y_coords")

    def __str__(self):
        """String representation of the SeriesData object."""
        return f"SeriesData(label='{self.label}', points={len(self.x_coords)})"


class PlotData:
    """
    Metadata container for plot configuration. The actual series data are
    held in SeriesData objects and can be added using the Class methods
    add_series or add_series_obj after initialization of the PlotData.

    Members:
        title: Plot title
        xlabel: X-axis label
        ylabel: Y-axis label
        savename: Save filename (extension is added automatically)
        show_legend: bool indicating whether to show legend
        datamin: List [xmin, ymin] forcing the bottom left coordinate to show.
        If omitted, the range is set to the data range.
        datamax: List [xmax, ymax] forcing the top right coordinate to show.
        If omitted, the range is set to the data range.
    """

    def __init__(
        self,
        title: Optional[str] = "Data Plot",
        xlabel: Optional[str] = "X",
        ylabel: Optional[str] = "Y",
        savename: Optional[str] = "plot",
        show_legend: Optional[bool] = True,
        datamin: Optional[List[float]] = None,
        datamax: Optional[List[float]] = None
    ):
        """
        Metadata container for plot configuration.

        Args:
            title: Plot title
            xlabel: X-axis label
            ylabel: Y-axis label
            savename: Save filename (extension is added automatically)
            show_legend: bool indicating whether to show legend
            datamin: List [xmin, ymin] forcing the bottom left coordinate to show
            datamax: List [xmax, ymax] forcing the top right coordinate to show
        """
        self.title = title
        self.xlabel = xlabel
        self.ylabel = ylabel
        self.savename = savename
        self.show_legend = show_legend
        self.series_data: List[SeriesData] = []
        self.datamin = datamin
        self.datamax = datamax

        # Validate datamin and datamax
        if self.datamin is not None:
            if not isinstance(self.datamin, list):
                raise TypeError("datamin must be a list of 2 numeric values (integers or floats)")
            if len(self.datamin) != 2:
                raise ValueError("datamin must contain exactly 2 numeric values (integers or floats)")
            if not all(isinstance(x, (int, float)) for x in self.datamin):
                raise TypeError("datamin must contain only numeric values (integers or floats)")

        if self.datamax is not None:
            if not isinstance(self.datamax, list):
                raise TypeError("datamax must be a list of 2 numeric values (integers or floats)")
            if len(self.datamax) != 2:
                raise ValueError("datamax must contain exactly 2 numeric values (integers or floats)")
            if not all(isinstance(x, (int, float)) for x in self.datamax):
                raise TypeError("datamax must contain only numeric values (integers or floats)")

    def add_series(
        self,
        x_coords: Union[List[float], np.ndarray],
        y_coords: Union[List[float], np.ndarray],
        label: Optional[str] = None,
        plot_type: Optional[PlotType] = PlotType.LINES,
        n_error: Optional[Union[List[float], np.ndarray]] = None,
        p_error: Optional[Union[List[float], np.ndarray]] = None
    ) -> SeriesData:
        """
        Add a new series to the plot metadata.

        Returns:
            SeriesData: the created SeriesData object for further manipulation if needed.
        """
        series = SeriesData(
            x_coords,
            y_coords,
            label,
            plot_type,
            n_error,
            p_error
        )
        self.series_data.append(series)
        return series

    def add_series_obj(self, series: SeriesData) -> None:
        """
        Add a pre-created SeriesData object to the plot metadata.

        Returns: None
        """
        self.series_data.append(series)

    @classmethod
    def serialize(cls, plot_data: 'PlotData') -> Tuple[bytes, int]:
        """
        Serialize plot data for shared memory transfer using network byte order.

        Args:
            plot_data: PlotData object containing plot configuration

        Returns:
            Tuple of serialized bytes and total length
        """
        def encode_null_string(s):
            return s.encode('utf-8') + b'\x00'

        serialized = b''
        serialized += encode_null_string(plot_data.title or "")
        serialized += encode_null_string(plot_data.xlabel or "")
        serialized += encode_null_string(plot_data.ylabel or "")
        serialized += encode_null_string(plot_data.savename or "")

        # Pack boolean and number of series
        serialized += struct.pack('!?', plot_data.show_legend)
        serialized += struct.pack('!I', len(plot_data.series_data))

        # If datamin is set, serialize it
        serialized += struct.pack('!?', plot_data.datamin is not None)
        if plot_data.datamin is not None:
            serialized += struct.pack('!dd', plot_data.datamin[0], plot_data.datamin[1])

        # If datamax is set, serialize it
        serialized += struct.pack('!?', plot_data.datamax is not None)
        if plot_data.datamax is not None:
            serialized += struct.pack('!dd', plot_data.datamax[0], plot_data.datamax[1])

        for series in plot_data.series_data:
            with_errors = series.n_error is not None or series.p_error is not None
            serialized += encode_null_string(series.label)
            serialized += struct.pack('!?', with_errors)
            serialized += struct.pack('!I', len(series.x_coords))
            serialized += struct.pack('!I', series.plot_type.value)

            # Serialize coordinates with optional error bars for each point
            for i, (x, y) in enumerate(zip(series.x_coords, series.y_coords)):
                # Serialize x and y coordinates
                serialized += struct.pack('!dd', x, y)

                if with_errors:
                    # Serialize negative error (if exists, otherwise 0)
                    if series.n_error is not None and i < len(series.n_error):
                        serialized += struct.pack('!d', series.n_error[i])
                    else:
                        serialized += struct.pack('!d', 0.0)

                    # Serialize positive error (if exists, otherwise 0)
                    if series.p_error is not None and i < len(series.p_error):
                        serialized += struct.pack('!d', series.p_error[i])
                    else:
                        serialized += struct.pack('!d', 0.0)

        return serialized, len(serialized)