hdhelpers 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hdhelpers/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ from hdhelpers.exceptions import ComponentException, HelperException, InsufficientPlottingData
2
+ from hdhelpers.helper_functions import (
3
+ _get_display_name,
4
+ _get_end_timestamp,
5
+ _get_start_timestamp,
6
+ _get_unit,
7
+ _pad_end,
8
+ _pad_start,
9
+ _to_datetime,
10
+ )
11
+ from hdhelpers.plot_target_settings import (
12
+ PlotTargetSettings,
13
+ PlotTargetStyle,
14
+ StatusColors,
15
+ get_plot_target_settings,
16
+ )
17
+ from hdhelpers.user_functions import (
18
+ get_and_pad_start_and_end_timestamp,
19
+ get_colors_from_plot_target_settings,
20
+ get_locale_from_plot_target_settings,
21
+ get_y_axis_label,
22
+ modify_timezone,
23
+ plotly_fig_to_json_dict,
24
+ )
25
+
26
+ __all__ = [
27
+ "ComponentException",
28
+ "HelperException",
29
+ "InsufficientPlottingData",
30
+ "PlotTargetSettings",
31
+ "PlotTargetStyle",
32
+ "StatusColors",
33
+ "_get_display_name",
34
+ "_get_end_timestamp",
35
+ "_get_start_timestamp",
36
+ "_get_unit",
37
+ "_pad_end",
38
+ "_pad_start",
39
+ "_to_datetime",
40
+ "get_and_pad_start_and_end_timestamp",
41
+ "get_colors_from_plot_target_settings",
42
+ "get_locale_from_plot_target_settings",
43
+ "get_plot_target_settings",
44
+ "get_y_axis_label",
45
+ "modify_timezone",
46
+ "plotly_fig_to_json_dict",
47
+ ]
@@ -0,0 +1,50 @@
1
+ from typing import Any
2
+
3
+
4
+ class ComponentException(Exception):
5
+ """Exception to re-raise exceptions with error code raised in the component code."""
6
+
7
+ __is_hetida_designer_exception__ = True
8
+
9
+ def __init__(
10
+ self,
11
+ *args: Any,
12
+ error_code: int | str = "",
13
+ extra_information: dict | None = None,
14
+ **kwargs: Any,
15
+ ) -> None:
16
+ if not isinstance(error_code, int | str):
17
+ raise ValueError("The ComponentException.error_code must be int or string!")
18
+ self.error_code = error_code
19
+ self.extra_information = extra_information
20
+ super().__init__(*args, **kwargs)
21
+
22
+
23
+ class HelperException(Exception):
24
+ """Exception to re-raise exceptions with error code raised in the code of the hdhelpers
25
+ package."""
26
+
27
+ __is_hetida_designer_exception__ = True
28
+
29
+ def __init__(
30
+ self,
31
+ *args: Any,
32
+ error_code: int | str = "",
33
+ extra_information: dict | None = None,
34
+ **kwargs: Any,
35
+ ) -> None:
36
+ if not isinstance(error_code, int | str):
37
+ raise ValueError("The HelperException.error_code must be int or string!")
38
+ self.error_code = error_code
39
+ self.extra_information = extra_information
40
+ super().__init__(*args, **kwargs)
41
+
42
+
43
+ class InsufficientPlottingData(ComponentException):
44
+ """A plot component has insufficient data to generate a meaningful plot
45
+
46
+ This exception class should be used when custom plots generated by hetida
47
+ designer are integrated in other frontends. This allows the frontend to
48
+ handle the case of e.g. no data to show in a sensible way, surpressing an
49
+ empty plot and showing an adequate message instead.
50
+ """
@@ -0,0 +1,178 @@
1
+ import logging
2
+ from datetime import datetime
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+ from pandas.tseries.frequencies import to_offset
7
+
8
+ from hdhelpers.exceptions import HelperException
9
+ from hdhelpers.plot_target_settings import get_plot_target_settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _convert_to_optional_timezone(object_to_convert: Any, to_timezone: str | None) -> Any:
15
+ """Convert object_to_convert to to_timezone if not None,
16
+ or to its own timezone if aware
17
+ or to UTC otherwise"""
18
+ if to_timezone is None:
19
+ if object_to_convert.tz is None:
20
+ return object_to_convert.tz_localize("UTC")
21
+ return object_to_convert
22
+ if object_to_convert.tz is None:
23
+ return object_to_convert.tz_localize(to_timezone)
24
+ return object_to_convert.tz_convert(to_timezone)
25
+
26
+
27
+ def _get_display_name(series: pd.Series, default_title: str = "") -> str:
28
+ """Get name for y-axis label from metadata
29
+
30
+ Tries to get the name from series.attrs according to the conventions of thehetida platform.
31
+ If such metadata doesn't exist, the default_title is returned instead.
32
+ """
33
+ try:
34
+ title = (
35
+ series.attrs.get("single_metric_metadata", {})
36
+ .get("structured_metadata", {})
37
+ .get("metric", {})["short_display_name"]
38
+ )
39
+ if not isinstance(title, str):
40
+ raise HelperException("Expected short_display_name to be a string, but it is not!")
41
+ except (KeyError, HelperException) as exc:
42
+ msg = (
43
+ 'Expected attrs["single_metric_metadata"]["structured_metadata"]["metric"]',
44
+ '["short_display_name"] but got incorrect keys',
45
+ )
46
+ logger.warning(msg=msg, exc_info=exc)
47
+ title = default_title
48
+ return title
49
+
50
+
51
+ def _get_unit(series: pd.Series, default_unit: str = "") -> str:
52
+ """Get unit for y-axis label from metadata
53
+
54
+ Tries to get the unit from series.attrs according to the conventions of thehetida platform.
55
+ If such metadata doesn't exist, the default_unit is returned instead.
56
+ """
57
+ try:
58
+ unit = (
59
+ series.attrs.get("single_metric_metadata", {})
60
+ .get("structured_metadata", {})
61
+ .get("metric", {})["unit"]
62
+ )
63
+ if not isinstance(unit, str):
64
+ raise HelperException("Expected unit to be a string, but it is not!")
65
+ except (KeyError, HelperException) as exc:
66
+ msg = 'Expected attrs["single_metric_metadata"]["structured_metadata"]["metric"]["unit"'
67
+ "] but got incorrect keys"
68
+ logger.warning(msg=msg, exc_info=exc)
69
+ unit = default_unit
70
+ return unit
71
+
72
+
73
+ def _pad_start(timestamp: pd.Timestamp, padding: str | None) -> pd.Timestamp:
74
+ """Subtracts padding from the timestamp
75
+
76
+ That padding has to be formatted to be compatible with pandas.tseries.frequencies.to_offset().
77
+ """
78
+ if padding is None:
79
+ return timestamp
80
+ try:
81
+ return timestamp - to_offset(padding)
82
+ except ValueError as exc:
83
+ raise HelperException(
84
+ f"{padding} as padding value is an invalid frequency. "
85
+ "Use something compatible with pandas.tseries.frequencies.to_offset()"
86
+ ) from exc
87
+
88
+
89
+ def _pad_end(timestamp: pd.Timestamp, padding: str | None) -> pd.Timestamp:
90
+ """Adds padding to the timestamp
91
+
92
+ That padding has to be formatted to be compatible with pandas.tseries.frequencies.to_offset().
93
+ """
94
+ if padding is None:
95
+ return timestamp
96
+ try:
97
+ return timestamp + to_offset(padding)
98
+ except ValueError as exc:
99
+ raise HelperException(
100
+ f"{padding} as padding value is an invalid frequency. "
101
+ "Use something compatible with pandas.tseries.frequencies.to_offset()"
102
+ ) from exc
103
+
104
+
105
+ def _get_start_timestamp(
106
+ series: pd.Series, timestamp: datetime | str | None
107
+ ) -> pd.Timestamp | None:
108
+ """Get the start timestamp hierarchically
109
+
110
+ Will check for an explicit input timestamp first, then check PlotTargetSettings, then the series
111
+ metadata, and if both are None, will take the first series entry as start timestamp.
112
+ If the series is also empty, None is returned.
113
+ """
114
+ if timestamp is not None:
115
+ return _to_datetime(timestamp)
116
+
117
+ plot_target_settings = get_plot_target_settings()
118
+
119
+ timestamp = plot_target_settings.datetime_x_axes_range_start
120
+
121
+ if timestamp is None:
122
+ key = "ref_interval_start_timestamp"
123
+ try:
124
+ timestamp = series.attrs.get("single_metric_dataset_metadata", {})[key]
125
+ except KeyError as exc:
126
+ msg = f"""Expected key structure not found:
127
+ attrs["single_metric_dataset_metadata"]["{key}"]"""
128
+ logger.warning(msg=msg, exc_info=exc)
129
+ if len(series) > 0:
130
+ timestamp = series.index[0]
131
+
132
+ return _to_datetime(timestamp)
133
+
134
+
135
+ def _get_end_timestamp(series: pd.Series, timestamp: datetime | str | None) -> pd.Timestamp | None:
136
+ """Get the end timestamp hierarchically
137
+
138
+ Will check for an explicit input timestamp first, then check PlotTargetSettings, then the series
139
+ metadata, and if both are None, will take the last series entry as end timestamp.
140
+ If the series is also empty, None is returned.
141
+ """
142
+ if timestamp is not None:
143
+ return _to_datetime(timestamp)
144
+
145
+ plot_target_settings = get_plot_target_settings()
146
+
147
+ timestamp = plot_target_settings.datetime_x_axes_range_end
148
+
149
+ if timestamp is None:
150
+ key = "ref_interval_end_timestamp"
151
+ try:
152
+ timestamp = series.attrs.get("single_metric_dataset_metadata", {})[key]
153
+ except KeyError as exc:
154
+ msg = f"""Expected key structure not found:
155
+ attrs["single_metric_dataset_metadata"]["{key}"]"""
156
+ logger.warning(msg=msg, exc_info=exc)
157
+ if len(series) > 0:
158
+ timestamp = series.index[-1]
159
+
160
+ return _to_datetime(timestamp)
161
+
162
+
163
+ def _to_datetime(timestamp: datetime | str | int | None) -> pd.Timestamp | None:
164
+ """Turn datetime string or integer into a pandas timestamp
165
+
166
+ Integer values are interpreted as epoch in seconds.
167
+ String values are accepted in any format compatible with pd.to_datetime
168
+ and interpreted in seconds.
169
+ The timezone is set to utc in both cases, other timezones can be set via modify_timezone."""
170
+ if timestamp is None:
171
+ return None
172
+ if isinstance(timestamp, int):
173
+ timestamp = pd.to_datetime(timestamp, unit="s", utc=True)
174
+ elif isinstance(timestamp, str | datetime):
175
+ timestamp = pd.to_datetime(timestamp, utc=True)
176
+ else:
177
+ raise HelperException("Unexpected timestamp type, please use str or int!")
178
+ return timestamp
@@ -0,0 +1,112 @@
1
+ import datetime
2
+ import logging
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class StatusColors(BaseModel):
10
+ """Collection of status-related colors
11
+
12
+ Unlike the other colors in PlotTargetSettings, these colors do not have a predefined use.
13
+ Instead they should be used contextually, e.g. when displaying the sensor status of an asset.
14
+ """
15
+
16
+ success_color: str | None = Field(
17
+ None, description="Color of markers that signal success as a hexcode"
18
+ )
19
+ error_color: str | None = Field(
20
+ None, description="Color of markers that signal errors as a hexcode"
21
+ )
22
+ warn_color: str | None = Field(
23
+ None, description="Color of markers that signal warnings as a hexcode"
24
+ )
25
+ info_color: str | None = Field(
26
+ None, description="Color of markers that signal informativeness as a hexcode"
27
+ )
28
+
29
+
30
+ class PlotTargetStyle(BaseModel):
31
+ axes_label_color: str | None = Field(
32
+ None, description="Color of the tick labels of all axes as a hex code"
33
+ )
34
+ background_color: str | None = Field(
35
+ None, description="Color of the panel background as a hex code"
36
+ )
37
+ grid_color: str | None = Field(
38
+ None, description="Color of the grid as a hex code that may be drawn into the background"
39
+ )
40
+ line_colors: list[str] | None = Field(
41
+ None,
42
+ description="""List of colors to be used for plot traces.
43
+ Will be set as colorway by plotly_fig_to_json_dict,
44
+ so the colors are only applied where no explicit trace color is set""",
45
+ )
46
+ status_colors: StatusColors = Field(
47
+ StatusColors(), # type: ignore
48
+ description="Has the properties success_color, error_color, warn_color, info_color",
49
+ )
50
+
51
+
52
+ class PlotTargetSettings(BaseModel):
53
+ """Settings that plot components can/should use
54
+
55
+ Some Plotly settings like locale or the timezone of timestamps must be set
56
+ by Python and cannot easily be set by plotly.js in a frontend.
57
+
58
+ They can be provided to execution endpoints as part of the ExecByIdBase payload,
59
+ are made accessible to components using the execution context.
60
+
61
+ hdhelpers provides helper functions to access them at runtime.
62
+ """
63
+
64
+ plot_target_timezone: str | None = Field(
65
+ None,
66
+ description="""The timezone plot components should use for datetime axes etc.
67
+ Usually via
68
+ s.index=pd.to_datetime(s.index, utc=True).tz_convert(plot_target_timezone)""",
69
+ examples=["Europe/Berlin"],
70
+ )
71
+ plot_target_locale: str | None = Field(
72
+ None,
73
+ description="""Locale to set for plots, e.g. to write weekdays in the user's language.
74
+ This has to be set in the config of the plotly figure dict and the plotly.js
75
+ must have the associated plotly local scripts loaded.""",
76
+ )
77
+ plot_target_style: PlotTargetStyle = Field(
78
+ PlotTargetStyle(), # type: ignore
79
+ description="Colors to use in the plot",
80
+ )
81
+ datetime_tick_format: str | None = Field(
82
+ None, description="Tickformat to use for datetime axes", examples=["%H:%M<br>%d.%m.%Y"]
83
+ )
84
+ datetime_x_axes_range_start: datetime.datetime | None = Field(
85
+ None, description="datetime range start which plots should set as x axis range"
86
+ )
87
+
88
+ datetime_x_axes_range_end: datetime.datetime | None = Field(
89
+ None, description="datetime range end which plots should set as x axis range"
90
+ )
91
+
92
+
93
+ def get_plot_target_settings() -> PlotTargetSettings:
94
+ """Obtain plot settings from runtime execution context.
95
+
96
+ If hetdesrun is not importable or this context field is not set,
97
+ return default values.
98
+ """
99
+ try:
100
+ from hetdesrun.runtime.context import ( # type: ignore # noqa: PLC0415
101
+ get_runtime_exec_context,
102
+ )
103
+
104
+ plot_target_settings = get_runtime_exec_context().plot_target_settings
105
+ if not isinstance(plot_target_settings, PlotTargetSettings):
106
+ raise TypeError("plot_target_settings must be instance of PlotTargetSettings")
107
+
108
+ return plot_target_settings
109
+ except (ImportError, TypeError):
110
+ logger.warning("Could not load runtime exec context, import failed! Switch to defaults.")
111
+ # return defaults if hetdesrun is not available as import
112
+ return PlotTargetSettings() # type: ignore
@@ -0,0 +1,348 @@
1
+ import json
2
+ import logging
3
+ from datetime import datetime
4
+ from typing import Any
5
+ from warnings import warn
6
+
7
+ import pandas as pd
8
+ import pytz
9
+ from plotly.graph_objects import Figure # type: ignore
10
+ from plotly.utils import PlotlyJSONEncoder # type: ignore
11
+
12
+ from hdhelpers.exceptions import HelperException
13
+ from hdhelpers.helper_functions import (
14
+ _convert_to_optional_timezone,
15
+ _get_display_name,
16
+ _get_end_timestamp,
17
+ _get_start_timestamp,
18
+ _get_unit,
19
+ _pad_end,
20
+ _pad_start,
21
+ )
22
+ from hdhelpers.plot_target_settings import PlotTargetStyle, get_plot_target_settings
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def get_colors_from_plot_target_settings() -> PlotTargetStyle:
28
+ """Get thematically coherent colors for customizing plots
29
+
30
+ Most color uses are already covered by the default settings of plotly_fig_to_json_dict().
31
+ They are still included here in case coloring other plot elements in the same color is desired.
32
+ Each color is given as a hex code, line_colors is a list of such, as specified in
33
+ PlotTargetStyle.
34
+ """
35
+ plot_target_settings = get_plot_target_settings()
36
+
37
+ return plot_target_settings.plot_target_style
38
+
39
+
40
+ def get_locale_from_plot_target_settings() -> str | None:
41
+ """Get language for customizing text elements in plots
42
+
43
+ Axis ticks are already covered by the default settings of plotly_fig_to_json_dict().
44
+ The language of custom text elements should be adjusted to the locale.
45
+ """
46
+ plot_target_settings = get_plot_target_settings()
47
+
48
+ return plot_target_settings.plot_target_locale
49
+
50
+
51
+ def get_y_axis_label(series: pd.Series, default_title: str = "", default_unit: str = "") -> str:
52
+ """Get full y-axis label from metadata
53
+
54
+ Combines the title and unit provided by _get_display_name and _get_unit.
55
+ """
56
+ title = _get_display_name(series, default_title)
57
+ unit = _get_unit(series, default_unit)
58
+ if len(unit) > 0:
59
+ title = f"{title} [{unit}]"
60
+ return title
61
+
62
+
63
+ def get_and_pad_start_and_end_timestamp(
64
+ series: pd.Series,
65
+ timezone: str | None = None,
66
+ start: datetime | str | None = None,
67
+ start_padding: str | None = None,
68
+ end: datetime | str | None = None,
69
+ end_padding: str | None = None,
70
+ ) -> tuple[pd.Timestamp, pd.Timestamp]:
71
+ """Get time period displayed on the x-axis
72
+
73
+ Retrieves the start and end timestamps, prioritizing the explicit "start" and "end" parameters
74
+ over the metadata of "series" and using the first and last index of the series if neither is
75
+ given. If a padding is given, the respective timestamp is adjusted. That padding has to be
76
+ formatted to be compatible with pandas.tseries.frequencies.to_offset().
77
+ """
78
+ # Get start and end
79
+ start = _get_start_timestamp(series, start)
80
+ end = _get_end_timestamp(series, end)
81
+
82
+ if start is None:
83
+ raise HelperException("No start timestamp found!")
84
+ start_timestamp = start
85
+ if end is None:
86
+ raise HelperException("No end timestamp found!")
87
+ end_timestamp = end
88
+
89
+ # Convert timezone
90
+ if timezone is not None:
91
+ start_with_timezone = modify_timezone(start_timestamp, timezone)
92
+ end_with_timezone = modify_timezone(end_timestamp, timezone)
93
+ else:
94
+ start_with_timezone = start_timestamp
95
+ end_with_timezone = end_timestamp
96
+
97
+ # Optionally add padding
98
+ start_padded = _pad_start(start_with_timezone, start_padding)
99
+ end_padded = _pad_end(end_with_timezone, end_padding)
100
+
101
+ return start_padded, end_padded
102
+
103
+
104
+ def modify_timezone[T: (pd.Timestamp, pd.Series, pd.DataFrame)]( # noqa: PLR0912
105
+ object_to_convert: T,
106
+ to_timezone: str | None = None,
107
+ column_name: str | None = None,
108
+ column_names: list[str] | None = None,
109
+ convert_index: bool = True,
110
+ ) -> T:
111
+ """Modifies timestamps to a certain timezone
112
+
113
+ Keyword arguments:
114
+ object_to_convert -- pd.Timestamp, pd.Series or pd.DataFrame where timezone is modified
115
+ to_timezone -- timezone to convert to, e.g. for German time use Europe/Berlin.
116
+ See possible timezone strings in pandas tz_convert method or pytz all_timezones list.
117
+ column_name -- column_name to apply, default is index as pd.Series have timestamps in index
118
+ """
119
+ if not isinstance(object_to_convert, pd.Timestamp | pd.Series | pd.DataFrame):
120
+ raise TypeError(
121
+ f"object_to_convert is {type(object_to_convert)} not pd.Series | pd.DataFrame"
122
+ )
123
+ if column_names is None:
124
+ column_names = []
125
+
126
+ try:
127
+ if to_timezone is None:
128
+ plot_target_settings = get_plot_target_settings()
129
+ if plot_target_settings.plot_target_timezone is not None:
130
+ to_timezone = plot_target_settings.plot_target_timezone
131
+
132
+ if isinstance(object_to_convert, pd.Timestamp):
133
+ return _convert_to_optional_timezone(object_to_convert, to_timezone)
134
+
135
+ if isinstance(object_to_convert, pd.Series):
136
+ new_object = object_to_convert.to_frame(name=object_to_convert.name)
137
+ else:
138
+ new_object = object_to_convert.copy(deep=True)
139
+
140
+ # Both column_name branches exist purely for backwards compatibility,
141
+ # only convert_index should stay.
142
+ if column_name is None and convert_index:
143
+ new_object.index = _convert_to_optional_timezone(
144
+ pd.to_datetime(new_object.index), to_timezone
145
+ )
146
+ if column_name is not None:
147
+ warn(
148
+ """The parameter 'column_name' will soon be deprecated in favor of
149
+ the more flexible 'columns_names'""",
150
+ DeprecationWarning,
151
+ stacklevel=2,
152
+ )
153
+ new_object[column_name] = _convert_to_optional_timezone(
154
+ pd.to_datetime(new_object[column_name]).dt, to_timezone
155
+ )
156
+ column_names.append(column_name)
157
+
158
+ if len(column_names) == 0:
159
+ if isinstance(object_to_convert, pd.Series):
160
+ new_object.index = _convert_to_optional_timezone(
161
+ pd.to_datetime(new_object.index), to_timezone
162
+ )
163
+ msg = f"Converted index to datetime starting with {object_to_convert.index[0]}"
164
+ logger.debug(msg=msg)
165
+ elif isinstance(new_object, pd.DataFrame) and "timestamp" in new_object.columns:
166
+ new_object["timestamp"] = _convert_to_optional_timezone(
167
+ pd.to_datetime(new_object["timestamp"]).dt, to_timezone
168
+ )
169
+ msg = f"""Converted column "timestamp" to datetime starting with
170
+ {object_to_convert["timestamp"][0]}"""
171
+ logger.debug(msg=msg)
172
+ if len(column_names) > 0:
173
+ for column in column_names:
174
+ new_object[column] = _convert_to_optional_timezone(
175
+ pd.to_datetime(new_object[column]).dt, to_timezone
176
+ )
177
+
178
+ if not isinstance(object_to_convert, pd.Series):
179
+ new_object.attrs = object_to_convert.attrs
180
+ return new_object
181
+
182
+ series_object = pd.Series(
183
+ new_object[object_to_convert.name],
184
+ index=new_object.index,
185
+ name=object_to_convert.name,
186
+ )
187
+ series_object.attrs = object_to_convert.attrs
188
+
189
+ return series_object
190
+
191
+ except pytz.exceptions.UnknownTimeZoneError as exc:
192
+ possible_timezone = pytz.all_timezones
193
+ raise ValueError(f"""Timezone not known, please choose from {possible_timezone}""") from exc
194
+ except (AttributeError, pytz.exceptions.NonExistentTimeError) as exc:
195
+ raise TypeError("Entries to convert do not contain valid timestamps") from exc
196
+ except KeyError as exc:
197
+ exc.add_note(f"Column name {column_name} not in object_to_convert")
198
+ raise
199
+
200
+
201
+ def plotly_fig_to_json_dict( # noqa: PLR0912, PLR0915
202
+ fig: Figure,
203
+ add_config_settings: bool = True,
204
+ hide_legend: bool | None = None,
205
+ hide_x_title: bool | None = None,
206
+ remove_plotly_bar: bool | None = None,
207
+ remove_plotly_icon: bool = True,
208
+ update_x_axes_tickformat: bool | None = None,
209
+ use_default_standoff: bool = False,
210
+ use_minimum_margin: bool = True,
211
+ use_muplot_axes_color: bool | None = None,
212
+ use_muplot_grid: bool | None = None,
213
+ use_muplot_line_and_markers: bool | None = None,
214
+ use_platform_background: bool | None = None,
215
+ use_platform_colorway: bool = True,
216
+ use_platform_defaults: bool = True,
217
+ use_simple_white_template: bool = True,
218
+ ) -> Any:
219
+ """Turn Plotly figure into a Python dict-like object
220
+
221
+ This function can be used in visualization components to obtain the
222
+ correct plotly json-like object from a Plotly Figure object.
223
+
224
+ Additionally, this function has a dozen boolean parameters that can be
225
+ set to standardize certain aspects of the plot styling in accordance
226
+ with the hetida platform.
227
+
228
+ See visualization components from the accompanying base components for
229
+ examples on usage.
230
+ """
231
+ if use_platform_defaults:
232
+ if hide_legend is None:
233
+ hide_legend = True
234
+ if hide_x_title is None:
235
+ hide_x_title = True
236
+ if remove_plotly_bar is None:
237
+ remove_plotly_bar = True
238
+ if update_x_axes_tickformat is None:
239
+ update_x_axes_tickformat = True
240
+ if use_default_standoff is None:
241
+ use_default_standoff = True
242
+ if use_muplot_axes_color is None:
243
+ use_muplot_axes_color = True
244
+ if use_muplot_grid is None:
245
+ use_muplot_grid = True
246
+ if use_muplot_line_and_markers is None:
247
+ use_muplot_line_and_markers = True
248
+ if use_platform_background is None:
249
+ use_platform_background = True
250
+ else:
251
+ if hide_legend is None:
252
+ hide_legend = False
253
+ if hide_x_title is None:
254
+ hide_x_title = False
255
+ if remove_plotly_bar is None:
256
+ remove_plotly_bar = False
257
+ if update_x_axes_tickformat is None:
258
+ update_x_axes_tickformat = False
259
+ if use_default_standoff is None:
260
+ use_default_standoff = False
261
+ if use_muplot_axes_color is None:
262
+ use_muplot_axes_color = False
263
+ if use_muplot_grid is None:
264
+ use_muplot_grid = False
265
+ if use_muplot_line_and_markers is None:
266
+ use_muplot_line_and_markers = False
267
+ if use_platform_background is None:
268
+ use_platform_background = False
269
+
270
+ plot_target_settings = get_plot_target_settings()
271
+
272
+ if use_platform_colorway and plot_target_settings.plot_target_style.line_colors is not None:
273
+ fig.update_layout(colorway=plot_target_settings.plot_target_style.line_colors)
274
+
275
+ if use_simple_white_template:
276
+ fig.update_layout({"template": "simple_white"})
277
+
278
+ if (
279
+ use_platform_background
280
+ and plot_target_settings.plot_target_style.background_color is not None
281
+ ):
282
+ fig.update_layout(
283
+ {
284
+ "paper_bgcolor": plot_target_settings.plot_target_style.background_color,
285
+ "plot_bgcolor": "rgba(0,0,0,0)",
286
+ }
287
+ )
288
+
289
+ if hide_legend:
290
+ fig.update_layout(showlegend=False)
291
+
292
+ if hide_x_title:
293
+ fig.update_xaxes(title_text="")
294
+
295
+ if update_x_axes_tickformat and plot_target_settings.datetime_tick_format is not None:
296
+ fig.update_xaxes(tickformat=plot_target_settings.datetime_tick_format)
297
+
298
+ if (
299
+ use_muplot_axes_color
300
+ and plot_target_settings.plot_target_style.axes_label_color is not None
301
+ ):
302
+ fig.update_xaxes(color=plot_target_settings.plot_target_style.axes_label_color)
303
+ fig.update_yaxes(color=plot_target_settings.plot_target_style.axes_label_color)
304
+
305
+ if use_default_standoff:
306
+ fig.update_yaxes(title_standoff=5)
307
+
308
+ if use_muplot_line_and_markers:
309
+ fig.update_traces(
310
+ {
311
+ "marker": {"size": 3},
312
+ "line": {"width": 1},
313
+ "mode": "lines+markers",
314
+ "marker_symbol": "circle",
315
+ }
316
+ )
317
+
318
+ if use_minimum_margin:
319
+ fig.update_layout(
320
+ {"margin": {"autoexpand": True, "l": 0, "r": 0, "b": 0, "t": 0, "pad": 0}}
321
+ )
322
+
323
+ if use_muplot_grid and plot_target_settings.plot_target_style.grid_color is not None:
324
+ grid_dict = {
325
+ "showgrid": True,
326
+ "gridcolor": plot_target_settings.plot_target_style.grid_color,
327
+ "zeroline": True,
328
+ "zerolinecolor": plot_target_settings.plot_target_style.grid_color,
329
+ }
330
+ fig.update_layout({"xaxis": grid_dict, "yaxis": grid_dict})
331
+
332
+ fig_dict_obj = fig.to_plotly_json()
333
+ if not "config" in fig_dict_obj:
334
+ fig_dict_obj["config"] = {}
335
+
336
+ if add_config_settings and plot_target_settings.plot_target_locale is not None:
337
+ fig_dict_obj["config"]["locale"] = plot_target_settings.plot_target_locale
338
+
339
+ if remove_plotly_bar:
340
+ fig_dict_obj["config"]["displayModeBar"] = False
341
+
342
+ if remove_plotly_icon:
343
+ fig_dict_obj["config"]["displaylogo"] = False
344
+
345
+ # possibly quite inefficient (multiple serialisation / deserialization) but
346
+ # guarantees that the PlotlyJSONEncoder is used and so the resulting Json
347
+ # should be definitely compatible with the plotly javascript library:
348
+ return json.loads(json.dumps(fig_dict_obj, cls=PlotlyJSONEncoder))
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: hdhelpers
3
+ Version: 0.0.1
4
+ Summary: Streamlines plotting and timezone handling in hetida designer components
5
+ Project-URL: homepage, https://fuseki.com/data-science/hetida-designer/
6
+ Project-URL: documentation, https://github.com/hetida/hetida-designer/tree/release/docs
7
+ Project-URL: repository, https://github.com/hetida/hetida-designer
8
+ Author-email: Christoph Dingel <cdingel@fuseki.com>, Steffen Wittkamp <swittkamp@fuseki.com>
9
+ License: The MIT License (MIT)
10
+
11
+ Copyright © 2025 fuseki GmbH
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
18
+ License-File: LICENSE
19
+ Classifier: Development Status :: 2 - Pre-Alpha
20
+ Classifier: Environment :: Console
21
+ Classifier: Intended Audience :: Science/Research
22
+ Classifier: License :: OSI Approved :: MIT License
23
+ Classifier: Operating System :: Unix
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Scientific/Engineering
26
+ Requires-Python: >=3.13
27
+ Requires-Dist: pandas<3,>=2
28
+ Requires-Dist: plotly<7,>=6
29
+ Requires-Dist: pydantic<3,>=2
30
+ Description-Content-Type: text/markdown
31
+
32
+ # hdhelpers
33
+ ## What is hdhelpers?
34
+ hdhelpers is a package designed for and included in the standard installation of the [hetida designer](https://github.com/hetida/hetida-designer).
35
+
36
+ It contains functions that streamline plotting components, especially those that are used in the [hetida platform](https://hetida.io/), by
37
+ * accessing series metadata that complies with the hetida platform metadata scheme
38
+ * accessing metadata that the hetida platform writes into the hetida designer's `plot_target_settings` context variable
39
+ * adjusting the timezone of timestamps, series, and dataframes
40
+ * providing toggleable standardized styling options and json serialization for plotly plots
41
+
42
+ ## Getting Started with hdhelpers
43
+ Since the intended use of the hdhelpers package is as a part of the hetida designer, it is highly recommended to follow the [hetida designer setup guide](https://github.com/hetida/hetida-designer/blob/release/README.md#getting-started-with-hetida-designer).
44
+
45
+ For a specific example of how to use hdhelpers functionality in a hetida designer component, see [Example](#example).
46
+
47
+ ## Developing for hdhelpers
48
+ In the development of this package, [uv](https://docs.astral.sh/uv/) was used for setting up a virtual environnment, managing dependencies, building and publishing the package. Though its use is not technically required, the following instructions assume that you use uv, too.
49
+
50
+ ### Setting up a Development Environment
51
+ First, move to the `runtime/hdhelpers` subdirectory, which contains the hdhelpers package.
52
+
53
+ Create a virtual environment with `uv venv`, which you can then find in the `.venv` subdirectory. There, uv installs all dependencies defined in `pyproject.toml`.
54
+
55
+ All uv commands that need a python environment will use `.venv`, so you should use it for your development, too.
56
+
57
+ Finally, in case you need to add a new dependency, do so via `uv add <new_dependency>`. That way, uv finds versions of all dependencies that are compatible with each other.
58
+
59
+ ### Deploying Your Code
60
+ Once you are done writing your code, including unit tests, use `./run check` to see if your code quality is sufficient.
61
+
62
+ Before you build the package, , then set an appropriate [version number](https://packaging.python.org/en/latest/discussions/versioning/#semantic-versioning) in `pyproject.toml`.
63
+
64
+ To build the package and delete any files that are currently in the `dist` subdirectory, execute `rm -r dist && uv build`. [Hatchling](https://pypi.org/project/hatchling/), the build backend specified in `pyproject.toml`, will build a new sdist and wheel in the `dist` subdirectory.
65
+
66
+ The hetida designer docker compose dev setup installs hdhelpers from [TestPyPI](https://packaging.python.org/en/latest/guides/using-testpypi/) using the following command in the `Dockerfile-runtime`:
67
+ ```
68
+ RUN pip install -U --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ hdhelpers
69
+ ```
70
+ **Note: TestPyPI occasionally deletes projects to free up disk space. If your hetida designer docker compose dev setup cannot find hdhelpers, make sure the project is still on TestPyPI before following other debugging approaches!**
71
+
72
+ To publish the build from the `dist` subdirectory to TestPyPI, use `uv publish --index testpypi`. To do so, you need a TestPyPI account with a token to enter in the command line as password following the username "\_\_token__".
73
+
74
+ Next time your hetida designer docker compose dev setup builds the runtime container, it will install the hdhelpers version that you just deployed.
75
+
76
+ ## <a name="example"></a> Example
77
+ Let's say we want to plot the following timeseries in a style that looks good on a hetida platform dashboard.
78
+ ```
79
+ {
80
+ "__hd_wrapped_data_object__":"SERIES",
81
+ "__metadata__": {
82
+ "single_metric_metadata": {
83
+ "ref_interval_end_timestamp":"2020-01-01T08:17:00.000Z",
84
+ "ref_interval_start_timestamp": "2020-01-01T08:10:00.000Z",
85
+ "structured_metadata": {
86
+ "metric": {
87
+ "short_display_name": "Water Level",
88
+ "unit": "cm"
89
+ }
90
+ }
91
+ }
92
+ }, "__data__": {
93
+ "2020-01-01T08:10:00.000Z": 1,
94
+ "2020-01-01T08:15:00.000Z": 2,
95
+ "2020-01-01T08:16:00.000Z": 3,
96
+ "2020-01-01T08:17:00.000Z": 4
97
+ }
98
+ }
99
+ ```
100
+ Our component code might look like this:
101
+ ```
102
+ from hdhelpers import get_and_pad_start_and_end_timestamp, get_title_with_unit, modify_timezone, plotly_fig_to_json_dict
103
+ import plotly.graph_objects as go
104
+ ...
105
+ def main(*, series, timezone):
106
+ # entrypoint function for this component
107
+ # ***** DO NOT EDIT LINES ABOVE *****
108
+ # write your function code here.
109
+ series = modify_timezone(series, timezone)
110
+
111
+ fig = go.Figure([go.Scatter(x=series.index, y=series.values)])
112
+
113
+ start, end = get_and_pad_start_and_end_timestamp(series=series, timezone=timezone, start_padding='5s', end_padding='5s')
114
+ fig.update_xaxes(range=(start, end))
115
+
116
+ full_title = get_title_with_unit(series=series, default_title="Level")
117
+ fig.update_layout(yaxis_title=full_title)
118
+
119
+ return {"plot": plotly_fig_to_json_dict(fig=fig, use_platform_defaults=True)}
120
+ ```
121
+ First, we use `modify_timezone` to set the timezone. Setting it to a string containing "plot_target", like "plot_target_timezone" or "plot_target_settings", will use the dashboard's timezone, but throw an exception if no such timezone is defined. Therefore, we might want to enter the timezone as a component input parameter with a default value of "plot_target_timezone", so it can be set to another value when the component is executed outside of the hetida platform.
122
+
123
+ With the timezone-corrected data in place, we turn it into a plotly scatter plot called `fig`, that we can then style to our liking.
124
+
125
+ Now, we use `get_and_pad_start_and_end_timestamp` for precise control over the x-axis range. We do not set `start` and `end` because we want to parse them from the series metadata. The timezone of the axis and that of the data should be the same, so we pass it to the function as `timezone` just like we did with `modify_timezone`. We also set a padding, so the markers of the first and last data point are not cut in half by the edge of the plot. With start and end parsed, we can update `fig`'s x-axis range.
126
+
127
+ Next, we use `get_title_with_unit` so our y-axis can be labeled with the series metadata. With the above input series, title and unit will be parsed from the series metadata, but in case the component is ever run without series metadata, we provide a `default_title`, but we leave the `default_unit` at its empty default value. Then, we update `fig` with our title.
128
+
129
+ Lastly, we use `plotly_fig_to_json_dict` to apply standardized stylings of our choice and serialize the plotly figure into a json dict. We set `use_platform_defaults` to True to switch on all the styling options at once, as detailed in [Styling Flags](#flags).
130
+
131
+ As a result we get the following plot:
132
+
133
+ ![hdhelpers example plot](./../../docs/assets/hdhelpers_example_plot.png)
134
+
135
+ ### <a name="flags"></a> Styling Flags
136
+ `use_platform_defaults=True` sets the following flags to `True`, which are by default `False`:
137
+ * `hide_legend` sets the plotly layout parameter `showlegend=False` to hide the plot's legend
138
+ * `hide_x_title` sets the plotly xaxes parameter `title_text=''` to hide the x-axis title
139
+ * `update_x_axes_tickformat` sets the plotly xaxes parameter `tickformat` to the `datetime_tick_format` property the hetida platform writes into the hetida designer's `plot_target_settings` context variable (unless the property is `None`)
140
+ * `use_default_standoff` sets the plotly yaxes parameter `title_standoff=5`
141
+ * `use_muplot_axes_color` sets the plotly xaxes and yaxes parameter `color` to the `axes_label_color` property the hetida platform writes into the hetida designer's `plot_target_settings` context variable (unless the property is `None`)
142
+ * `use_muplot_grid` makes the plotly grid visible and colors it in according to the `grid_color` property the hetida platform writes into the hetida designer's `plot_target_settings` context variable (unless the property is `None`)
143
+ * `use_muplot_line_and_markers` sets the plotly traces to the following style, which matches the hetida platform's µplots:
144
+ ```
145
+ {
146
+ "marker": {"size": 3},
147
+ "line": {"width": 1},
148
+ "mode": "lines+markers",
149
+ "marker_symbol": "circle",
150
+ }
151
+ ```
152
+ * `use_platform_background` sets the plotly layout parameter `paper_bgcolor` to the `background_color` property the hetida platform writes into the hetida designer's `plot_target_settings` context variable (unless the property is `None`) and it sets `plot_bgcolor=rgba(0,0,0,0)` so the "paper background" is visible through the "plot background"
153
+
154
+ `plotly_fig_to_json_dict` has five more boolean parameters:
155
+ * `add_config_settings` sets the plotly figure's locale to the `plot_target_locale` property the hetida platform writes into the hetida designer's `plot_target_settings` context variable (unless the property is `None`)
156
+ * `remove_plotly_bar` sets the plotly figure's `displayModeBar` setting to `False` to remove the plotly bar from the plot
157
+ * `remove_plotly_icon` sets the plotly figure's `displaylogo` setting to `False` to remove the plotly logo from the plot
158
+ * `use_minimum_margin` sets the plotly layout parameter `margin={"autoexpand": True, "l": 0, "r": 0, "b": 0, "t": 0, "pad": 0}` to minimize the plot's margins
159
+ * `use_simple_white_template` sets the plotly layout parameter `template=simple_white`
160
+
161
+ Beyond that, there is one more thing `plotly_fig_to_json_dict` does: It sets the `colorway` to the `line_colors` in `plot_target_settings` (unless they are `None`). This cannot be turned off because the colorway is automatically overwritten by any explicitly set line color, so it being "always on" does not hinder plot customization.
@@ -0,0 +1,9 @@
1
+ hdhelpers/__init__.py,sha256=h7Qovh2RU6Xlijn_ZPAIzu47uuTiZ_Uqy-WV123DKDA,1208
2
+ hdhelpers/exceptions.py,sha256=OCkqwt3-V00zWGVZQX89VcANr3_-JICNJIVS6h3mVOQ,1695
3
+ hdhelpers/helper_functions.py,sha256=KX5z-fkj9iNG5qu1PDnFlVWa82A-Ql-k5o805rxc7EU,6667
4
+ hdhelpers/plot_target_settings.py,sha256=w8zh4TIdNZAeHSkbHA4RJtto8Giq0o8i1uDVpw-AvWg,4353
5
+ hdhelpers/user_functions.py,sha256=Lns9dY_N5Yhy2_lP8GOpQAYp3q2aOsq0VCNo4tWXn7o,13332
6
+ hdhelpers-0.0.1.dist-info/METADATA,sha256=lRxBrZzpxoQU_w8p3V_sYimbSrGdHxAb_GY6-iIG31s,11921
7
+ hdhelpers-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ hdhelpers-0.0.1.dist-info/licenses/LICENSE,sha256=0czzBa9jPwpQLIqxOZk0zcQfAudLJBH-_4IRjEAr0zU,1086
9
+ hdhelpers-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2025 fuseki GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.