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 +47 -0
- hdhelpers/exceptions.py +50 -0
- hdhelpers/helper_functions.py +178 -0
- hdhelpers/plot_target_settings.py +112 -0
- hdhelpers/user_functions.py +348 -0
- hdhelpers-0.0.1.dist-info/METADATA +161 -0
- hdhelpers-0.0.1.dist-info/RECORD +9 -0
- hdhelpers-0.0.1.dist-info/WHEEL +4 -0
- hdhelpers-0.0.1.dist-info/licenses/LICENSE +9 -0
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
|
+
]
|
hdhelpers/exceptions.py
ADDED
|
@@ -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
|
+

|
|
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,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.
|