apmtools 0.2.41__tar.gz → 0.2.43__tar.gz
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.
- {apmtools-0.2.41 → apmtools-0.2.43}/PKG-INFO +2 -1
- {apmtools-0.2.41 → apmtools-0.2.43}/pyproject.toml +2 -1
- {apmtools-0.2.41 → apmtools-0.2.43}/src/apmtools/classes.py +18 -4
- {apmtools-0.2.41 → apmtools-0.2.43}/src/apmtools/shiny/__init__.py +1 -1
- apmtools-0.2.43/src/apmtools/shiny/appearance.py +260 -0
- apmtools-0.2.43/src/apmtools/shiny/columns.py +98 -0
- apmtools-0.2.43/src/apmtools/shiny/config.py +55 -0
- apmtools-0.2.43/src/apmtools/shiny/data.py +333 -0
- apmtools-0.2.43/src/apmtools/shiny/export.py +71 -0
- apmtools-0.2.43/src/apmtools/shiny/matplotlib_renderer.py +789 -0
- apmtools-0.2.43/src/apmtools/shiny/plotly_renderer.py +294 -0
- apmtools-0.2.43/src/apmtools/shiny/summary.py +14 -0
- apmtools-0.2.43/src/apmtools/shiny/ui.py +1149 -0
- apmtools-0.2.41/src/apmtools/shiny/summary.py +0 -2655
- {apmtools-0.2.41 → apmtools-0.2.43}/.gitignore +0 -0
- {apmtools-0.2.41 → apmtools-0.2.43}/LICENSE +0 -0
- {apmtools-0.2.41 → apmtools-0.2.43}/README.md +0 -0
- {apmtools-0.2.41 → apmtools-0.2.43}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.41 → apmtools-0.2.43}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.41 → apmtools-0.2.43}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.41 → apmtools-0.2.43}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.41 → apmtools-0.2.43}/src/apmtools/units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: apmtools
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.43
|
|
4
4
|
Summary: A collection of tools for processing air pollution monitoring data
|
|
5
5
|
Project-URL: Homepage, https://github.com/federlorenz/apmtools
|
|
6
6
|
Author-email: Federico Lorenzetti <lorenzetti.federico@gmail.com>
|
|
@@ -12,6 +12,7 @@ Requires-Python: >=3.8
|
|
|
12
12
|
Provides-Extra: shiny
|
|
13
13
|
Requires-Dist: plotly>=6.0; extra == 'shiny'
|
|
14
14
|
Requires-Dist: shiny>=1.10; extra == 'shiny'
|
|
15
|
+
Requires-Dist: shinyswatch>=0.12; extra == 'shiny'
|
|
15
16
|
Requires-Dist: shinywidgets>=0.6; extra == 'shiny'
|
|
16
17
|
Description-Content-Type: text/markdown
|
|
17
18
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "apmtools"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.43"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
|
|
10
10
|
]
|
|
@@ -24,5 +24,6 @@ Homepage = "https://github.com/federlorenz/apmtools"
|
|
|
24
24
|
shiny = [
|
|
25
25
|
"shiny>=1.10",
|
|
26
26
|
"shinywidgets>=0.6",
|
|
27
|
+
"shinyswatch>=0.12",
|
|
27
28
|
"plotly>=6.0",
|
|
28
29
|
]
|
|
@@ -257,6 +257,7 @@ class Dataset(DictionaryPlus):
|
|
|
257
257
|
def __init__(self, *args, **kwargs):
|
|
258
258
|
DictionaryPlus.__init__(self, *args, **kwargs)
|
|
259
259
|
self.filter_key = None
|
|
260
|
+
self.description = ""
|
|
260
261
|
|
|
261
262
|
@property
|
|
262
263
|
def _constructor(self):
|
|
@@ -285,6 +286,7 @@ class Dataset(DictionaryPlus):
|
|
|
285
286
|
if verbose:
|
|
286
287
|
print(key)
|
|
287
288
|
a.filter_key = self.filter_key
|
|
289
|
+
a.description = self.description + f"\n Applied function {func}"
|
|
288
290
|
return a
|
|
289
291
|
|
|
290
292
|
def subset(self, filter_dict={}, filter_style='all', condition=None):
|
|
@@ -435,6 +437,8 @@ class Dataset(DictionaryPlus):
|
|
|
435
437
|
a[key] = value
|
|
436
438
|
a = Dataset(a)
|
|
437
439
|
a.filter_key = self.filter_key
|
|
440
|
+
a.description = self.description + \
|
|
441
|
+
f"\nSubset by {filter_dict} on style {filter_style} by condition {condition}"
|
|
438
442
|
return a
|
|
439
443
|
else:
|
|
440
444
|
b = {}
|
|
@@ -443,10 +447,14 @@ class Dataset(DictionaryPlus):
|
|
|
443
447
|
b[key] = value
|
|
444
448
|
b = Dataset(b)
|
|
445
449
|
b.filter_key = self.filter_key
|
|
450
|
+
b.description = self.description + \
|
|
451
|
+
f"\nSubset by {filter_dict} on style {filter_style} by condition {condition}"
|
|
446
452
|
return b
|
|
447
453
|
else:
|
|
448
454
|
a = Dataset(a)
|
|
449
455
|
a.filter_key = self.filter_key
|
|
456
|
+
a.description = self.description + \
|
|
457
|
+
f"\nSubset by {filter_dict} on style {filter_style} by condition {condition}"
|
|
450
458
|
return a
|
|
451
459
|
|
|
452
460
|
def scan_folder(self, directory="./", levels=[], level=0, monitor=None, levels_dict=None, utc_timezone_shift=0, interpolate=False, add_m={}):
|
|
@@ -1026,16 +1034,18 @@ class Summary(pd.DataFrame):
|
|
|
1026
1034
|
def _constructor(self):
|
|
1027
1035
|
return Summary
|
|
1028
1036
|
|
|
1029
|
-
def __init__(self, *args, metadata=set(), variable=None,
|
|
1037
|
+
def __init__(self, *args, metadata=set(), variable=None, description="",**kwargs):
|
|
1030
1038
|
pd.DataFrame.__init__(self, *args, **kwargs)
|
|
1031
1039
|
|
|
1032
1040
|
self.metadata = metadata
|
|
1033
1041
|
self.variable = variable
|
|
1042
|
+
self.description = description
|
|
1034
1043
|
|
|
1035
1044
|
def __finalize__(self, other, method=None, **kwargs):
|
|
1036
1045
|
if isinstance(other, Summary):
|
|
1037
1046
|
self.variable = other.variable
|
|
1038
1047
|
self.metadata = other.metadata.copy()
|
|
1048
|
+
self.description = other.description
|
|
1039
1049
|
|
|
1040
1050
|
return self
|
|
1041
1051
|
|
|
@@ -1070,6 +1080,7 @@ class Summary(pd.DataFrame):
|
|
|
1070
1080
|
if filter_style not in {'all', 'any', 'negative all', 'negative any'}:
|
|
1071
1081
|
return return_dict
|
|
1072
1082
|
|
|
1083
|
+
|
|
1073
1084
|
# Only conditions referring to existing columns can be evaluated.
|
|
1074
1085
|
conditions = []
|
|
1075
1086
|
for column, values in filter_dict.items():
|
|
@@ -1091,6 +1102,8 @@ class Summary(pd.DataFrame):
|
|
|
1091
1102
|
mask = (~conditions).all(axis=0).to_numpy()
|
|
1092
1103
|
else: # negative any
|
|
1093
1104
|
mask = (~conditions).any(axis=0).to_numpy()
|
|
1105
|
+
|
|
1106
|
+
return_dict.description = self.description + f"\nSubset by {filter_dict} on style {filter_style}"
|
|
1094
1107
|
|
|
1095
1108
|
return return_dict.loc[mask]
|
|
1096
1109
|
|
|
@@ -1152,6 +1165,9 @@ class Summary(pd.DataFrame):
|
|
|
1152
1165
|
if day is not None:
|
|
1153
1166
|
self = self.loc[[a in day for a in [self["datetime"].map(lambda x: x.date().isoweekday())
|
|
1154
1167
|
for i in range(len(self["datetime"]))]]]
|
|
1168
|
+
|
|
1169
|
+
self.description = self.description + \
|
|
1170
|
+
f"\nFiltered by {time_start if time_start != None else ""} till {time_end if time_end != None else ""}, {date_start if date_start != None else ""} till {date_end if date_end != None else ""}, {day if day != None else ""}"
|
|
1155
1171
|
return self.reset_index()
|
|
1156
1172
|
|
|
1157
1173
|
def set_attrib(self, attribute):
|
|
@@ -1574,11 +1590,11 @@ class Summary(pd.DataFrame):
|
|
|
1574
1590
|
|
|
1575
1591
|
return output
|
|
1576
1592
|
|
|
1577
|
-
|
|
1578
1593
|
def get_monitor(self, value):
|
|
1579
1594
|
|
|
1580
1595
|
out = self.subset({"monitor": [value]})
|
|
1581
1596
|
out = out.dropna(axis=1,how="all")
|
|
1597
|
+
out.description = out.description + f"\nReturned monitor {value}"
|
|
1582
1598
|
return out
|
|
1583
1599
|
|
|
1584
1600
|
def plot_app(
|
|
@@ -2066,8 +2082,6 @@ class LcmSeries(ApmSeries):
|
|
|
2066
2082
|
def _constructor(self):
|
|
2067
2083
|
return LcmSeries
|
|
2068
2084
|
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
2085
|
class PolarH10(dict):
|
|
2072
2086
|
monitor = "polarh10"
|
|
2073
2087
|
def __init__(self, *args, **kwargs):
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Figure labels and appearance controls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import threading
|
|
4
|
+
from datetime import date, datetime, time, timedelta
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from ..units import upas_units, lascar_units, purple_units
|
|
9
|
+
|
|
10
|
+
from .config import VARIABLE_UNITS, PLOT_TYPES, FREQUENCIES, AGGREGATES, _MPL_PALETTES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _axis_label(variable: str) -> str:
|
|
14
|
+
"""Return a variable label with units, including aggregated columns.
|
|
15
|
+
|
|
16
|
+
Aggregation produces names such as ``pm25_mean`` or ``CO(ppm)_max``.
|
|
17
|
+
Strip only a *recognized* aggregation suffix, and only when the full
|
|
18
|
+
column name does not already have its own units entry.
|
|
19
|
+
"""
|
|
20
|
+
name = str(variable)
|
|
21
|
+
unit = VARIABLE_UNITS.get(name)
|
|
22
|
+
if unit is None:
|
|
23
|
+
for suffix in ("count", "max", "mean", "median", "min", "std", "sum"):
|
|
24
|
+
marker = f"_{suffix}"
|
|
25
|
+
if name.endswith(marker):
|
|
26
|
+
unit = VARIABLE_UNITS.get(name[:-len(marker)])
|
|
27
|
+
if unit is not None:
|
|
28
|
+
break
|
|
29
|
+
return f"{name} [{unit}]" if unit else name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
|
|
34
|
+
category_columns=None, x_label_override=None):
|
|
35
|
+
"""Label plot axes while retaining special histogram and facet labels."""
|
|
36
|
+
if plot_type == "histogram":
|
|
37
|
+
fig.update_xaxes(title_text=_axis_label(x_column))
|
|
38
|
+
elif plot_type in {"box", "bar"}:
|
|
39
|
+
categories = category_columns or [x_column]
|
|
40
|
+
fig.update_xaxes(title_text=" | ".join(_axis_label(c) for c in categories))
|
|
41
|
+
fig.update_yaxes(title_text=_axis_label(y_columns[0]))
|
|
42
|
+
elif plot_type == "line" and len(y_columns) > 1:
|
|
43
|
+
fig.update_xaxes(title_text=_axis_label(x_column))
|
|
44
|
+
# The values axis is shared by multiple series; facet headings carry
|
|
45
|
+
# the variable-specific unit rather than misleadingly naming one unit.
|
|
46
|
+
fig.update_yaxes(title_text="Value")
|
|
47
|
+
for annotation in fig.layout.annotations:
|
|
48
|
+
label = str(annotation.text)
|
|
49
|
+
if label.startswith("variable="):
|
|
50
|
+
annotation.text = _axis_label(label.split("=", 1)[1])
|
|
51
|
+
else:
|
|
52
|
+
fig.update_xaxes(title_text=_axis_label(x_column))
|
|
53
|
+
if y_columns:
|
|
54
|
+
fig.update_yaxes(title_text=_axis_label(y_columns[0]))
|
|
55
|
+
if x_label_override is not None:
|
|
56
|
+
fig.update_xaxes(title_text=x_label_override)
|
|
57
|
+
return fig
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_label_map(value):
|
|
62
|
+
"""Parse semicolon-separated original=replacement entries."""
|
|
63
|
+
result = {}
|
|
64
|
+
for entry in (value or "").split(";"):
|
|
65
|
+
if "=" in entry:
|
|
66
|
+
key, replacement = entry.split("=", 1)
|
|
67
|
+
if key.strip() and replacement.strip():
|
|
68
|
+
result[key.strip()] = replacement.strip()
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _numeric_axis_controls(kind, box_orientation="vertical"):
|
|
74
|
+
"""Return axes on which numeric/date tick-density controls apply."""
|
|
75
|
+
if kind in ("line", "scatter", "histogram"):
|
|
76
|
+
return True, True
|
|
77
|
+
if kind == "box" and box_orientation == "horizontal":
|
|
78
|
+
return True, False
|
|
79
|
+
return False, kind in ("box", "bar")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _matplotlib_tick_controls(ax, *, kind, box_orientation="vertical", show_grid=True,
|
|
84
|
+
x_major_ticks=6, y_major_ticks=6,
|
|
85
|
+
x_minor_ticks=False, y_minor_ticks=False):
|
|
86
|
+
from matplotlib.ticker import MaxNLocator, AutoMinorLocator
|
|
87
|
+
from matplotlib.dates import AutoDateLocator
|
|
88
|
+
use_x, use_y = _numeric_axis_controls(kind, box_orientation)
|
|
89
|
+
for active, axis, count, minor in ((use_x, ax.xaxis, x_major_ticks, x_minor_ticks),
|
|
90
|
+
(use_y, ax.yaxis, y_major_ticks, y_minor_ticks)):
|
|
91
|
+
if not active:
|
|
92
|
+
continue
|
|
93
|
+
count = max(2, min(30, int(count or 6)))
|
|
94
|
+
if axis.get_scale() == "linear":
|
|
95
|
+
if isinstance(axis.get_major_locator(), AutoDateLocator):
|
|
96
|
+
axis.set_major_locator(AutoDateLocator(minticks=max(2, count - 2), maxticks=count))
|
|
97
|
+
else:
|
|
98
|
+
axis.set_major_locator(MaxNLocator(nbins=count))
|
|
99
|
+
if minor:
|
|
100
|
+
axis.set_minor_locator(AutoMinorLocator())
|
|
101
|
+
else:
|
|
102
|
+
axis.set_minor_locator(__import__("matplotlib.ticker", fromlist=["NullLocator"]).NullLocator())
|
|
103
|
+
axis.grid(bool(show_grid and minor), which="minor")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _apply_plot_appearance(fig, *, title, x_label="", y_label="",
|
|
108
|
+
legend_labels="", trace_colors="", palette="Default",
|
|
109
|
+
x_min="", x_max="", y_min="", y_max="",
|
|
110
|
+
template="plotly", legend_position="right", legend_inside=False,
|
|
111
|
+
show_grid=True, font_size=12, line_width=2,
|
|
112
|
+
marker_size=7, line_alpha="", line_style="", scatter_size="", scatter_marker="", scatter_alpha="",
|
|
113
|
+
hist_alpha="", box_showfliers=True, box_orientation="vertical",
|
|
114
|
+
title_font_size=14, axis_font_size=12, tick_font_size=10,
|
|
115
|
+
legend_font_size=12, panel_font_size=14, plot_kind=None,
|
|
116
|
+
x_major_ticks=6, y_major_ticks=6, x_minor_ticks=False, y_minor_ticks=False):
|
|
117
|
+
"""Apply per-plot appearance overrides without changing the source data."""
|
|
118
|
+
from plotly.colors import qualitative
|
|
119
|
+
|
|
120
|
+
fig.update_layout(title=title, template=template, font=dict(size=font_size),
|
|
121
|
+
title_font=dict(size=title_font_size),
|
|
122
|
+
legend=dict(font=dict(size=legend_font_size), title_font=dict(size=legend_font_size)))
|
|
123
|
+
fig.update_xaxes(title_font=dict(size=axis_font_size), tickfont=dict(size=tick_font_size))
|
|
124
|
+
fig.update_yaxes(title_font=dict(size=axis_font_size), tickfont=dict(size=tick_font_size))
|
|
125
|
+
if x_label.strip():
|
|
126
|
+
fig.update_xaxes(title_text=x_label.strip())
|
|
127
|
+
if y_label.strip():
|
|
128
|
+
fig.update_yaxes(title_text=y_label.strip())
|
|
129
|
+
fig.update_xaxes(showgrid=show_grid)
|
|
130
|
+
fig.update_yaxes(showgrid=show_grid)
|
|
131
|
+
use_x, use_y = _numeric_axis_controls(plot_kind, box_orientation)
|
|
132
|
+
for active, axis_name, count, minor in ((use_x, "x", x_major_ticks, x_minor_ticks),
|
|
133
|
+
(use_y, "y", y_major_ticks, y_minor_ticks)):
|
|
134
|
+
if not active:
|
|
135
|
+
continue
|
|
136
|
+
update = getattr(fig, f"update_{axis_name}axes")
|
|
137
|
+
update(nticks=max(2, min(30, int(count or 6))), minor=dict(ticks="inside" if minor else "", showgrid=bool(show_grid and minor)))
|
|
138
|
+
for axis, lower, upper in (("x", x_min, x_max), ("y", y_min, y_max)):
|
|
139
|
+
datetime_axis = axis == "x" and any(
|
|
140
|
+
getattr(trace, "type", "") == "scatter" and
|
|
141
|
+
any(isinstance(v, (pd.Timestamp, datetime, np.datetime64)) for v in (trace.x[:3] if trace.x is not None else []))
|
|
142
|
+
for trace in fig.data
|
|
143
|
+
)
|
|
144
|
+
low = _parse_axis_limit(lower, datetime_axis=datetime_axis)
|
|
145
|
+
high = _parse_axis_limit(upper, datetime_axis=datetime_axis)
|
|
146
|
+
if low is not None or high is not None:
|
|
147
|
+
getattr(fig, f"update_{axis}axes")(range=[low, high], autorange=False)
|
|
148
|
+
positions = {
|
|
149
|
+
"right": dict(x=1.02, y=1, xanchor="left", yanchor="top"),
|
|
150
|
+
"left": dict(x=-0.1, y=1, xanchor="right", yanchor="top"),
|
|
151
|
+
"top": dict(x=0.5, y=1.13, xanchor="center", yanchor="bottom", orientation="h"),
|
|
152
|
+
"bottom": dict(x=0.5, y=-0.2, xanchor="center", yanchor="top", orientation="h"),
|
|
153
|
+
}
|
|
154
|
+
legend_settings = positions.get(legend_position, positions["right"]).copy()
|
|
155
|
+
if legend_inside:
|
|
156
|
+
legend_settings = {
|
|
157
|
+
"right": dict(x=0.99, y=0.99, xanchor="right", yanchor="top"),
|
|
158
|
+
"left": dict(x=0.01, y=0.99, xanchor="left", yanchor="top"),
|
|
159
|
+
"top": dict(x=0.5, y=0.99, xanchor="center", yanchor="top", orientation="h"),
|
|
160
|
+
"bottom": dict(x=0.5, y=0.01, xanchor="center", yanchor="bottom", orientation="h"),
|
|
161
|
+
}.get(legend_position, {})
|
|
162
|
+
legend_settings = {**legend_settings, "bgcolor": "rgba(255,255,255,0.7)"}
|
|
163
|
+
fig.update_layout(legend={**legend_settings, "title": {"text": "Legend", "font": {"size": legend_font_size}}, "font": {"size": legend_font_size}})
|
|
164
|
+
labels = _parse_label_map(legend_labels)
|
|
165
|
+
colors = _parse_label_map(trace_colors)
|
|
166
|
+
alphas, styles, sizes, markers, marker_alphas = _series_style_maps(
|
|
167
|
+
line_alpha, line_style, scatter_size, scatter_marker, scatter_alpha)
|
|
168
|
+
histogram_alphas = _parse_label_map(hist_alpha)
|
|
169
|
+
palette_values = getattr(qualitative, palette, None) if palette != "Default" else None
|
|
170
|
+
# Color keys can refer to either original or renamed trace labels.
|
|
171
|
+
for i, trace in enumerate(fig.data):
|
|
172
|
+
original = str(trace.name or "")
|
|
173
|
+
renamed = labels.get(original, original)
|
|
174
|
+
if renamed != original:
|
|
175
|
+
trace.name = renamed
|
|
176
|
+
if getattr(trace, "legendgroup", None) == original:
|
|
177
|
+
trace.legendgroup = renamed
|
|
178
|
+
color = colors.get(original, colors.get(renamed))
|
|
179
|
+
if color is None and palette_values:
|
|
180
|
+
color = palette_values[i % len(palette_values)]
|
|
181
|
+
if color:
|
|
182
|
+
if getattr(trace, "line", None) is not None:
|
|
183
|
+
trace.line.color = color
|
|
184
|
+
if getattr(trace, "marker", None) is not None:
|
|
185
|
+
trace.marker.color = color
|
|
186
|
+
if getattr(trace, "fillcolor", None) is not None and trace.type == "histogram":
|
|
187
|
+
trace.marker.color = color
|
|
188
|
+
if plot_kind == "histogram":
|
|
189
|
+
trace.opacity = _bounded_float(histogram_alphas, original, 0.65, 0, 1)
|
|
190
|
+
if plot_kind == "box":
|
|
191
|
+
trace.boxpoints = "outliers" if box_showfliers else False
|
|
192
|
+
if box_orientation == "horizontal":
|
|
193
|
+
old_x, old_y = trace.x, trace.y
|
|
194
|
+
trace.x, trace.y = old_y, old_x
|
|
195
|
+
trace.orientation = "h"
|
|
196
|
+
if getattr(trace, "line", None) is not None and plot_kind == "line":
|
|
197
|
+
trace.line.width = line_width
|
|
198
|
+
if plot_kind == "line":
|
|
199
|
+
trace.line.dash = {"-": "solid", ":": "dot", "--": "dash"}[_line_style(styles, original)]
|
|
200
|
+
trace.opacity = _bounded_float(alphas, original, 1, 0, 1)
|
|
201
|
+
if getattr(trace, "marker", None) is not None and trace.type in ("scatter", "scattergl"):
|
|
202
|
+
if plot_kind == "scatter":
|
|
203
|
+
trace.marker.size = _bounded_float(sizes, original, 7, 0.1, 100)
|
|
204
|
+
trace.marker.symbol = {"o": "circle", "x": "x"}[_scatter_marker(markers, original)]
|
|
205
|
+
trace.opacity = _bounded_float(marker_alphas, original, 1, 0, 1)
|
|
206
|
+
else:
|
|
207
|
+
trace.marker.size = marker_size
|
|
208
|
+
if plot_kind == "box" and box_orientation == "horizontal":
|
|
209
|
+
x_title = fig.layout.xaxis.title.text
|
|
210
|
+
y_title = fig.layout.yaxis.title.text
|
|
211
|
+
fig.update_xaxes(title_text=y_title, type="linear", autorange=True)
|
|
212
|
+
fig.update_yaxes(title_text=x_title, type="category", autorange=True)
|
|
213
|
+
return fig
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _series_style_maps(line_alpha="", line_style="", scatter_size="", scatter_marker="", scatter_alpha=""):
|
|
218
|
+
"""Parse per-trace styles, keyed by the original legend/series name."""
|
|
219
|
+
return tuple(_parse_label_map(v) for v in (line_alpha, line_style, scatter_size, scatter_marker, scatter_alpha))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _bounded_float(mapping, name, default, low, high):
|
|
224
|
+
try:
|
|
225
|
+
value = float(mapping.get(name, default))
|
|
226
|
+
return value if np.isfinite(value) and low <= value <= high else float(default)
|
|
227
|
+
except (ValueError, TypeError):
|
|
228
|
+
return float(default)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _line_style(mapping, name):
|
|
233
|
+
return {"solid": "-", "dotted": ":", "dashed": "--", "-": "-", ":": ":", "--": "--"}.get(
|
|
234
|
+
mapping.get(name, "solid").strip().lower(), "-")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _scatter_marker(mapping, name):
|
|
239
|
+
return {"dot": "o", "cross": "x", "o": "o", "x": "x"}.get(
|
|
240
|
+
mapping.get(name, "dot").strip().lower(), "o")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _parse_axis_limit(value, *, datetime_axis=False):
|
|
245
|
+
"""Parse numeric bounds or an ISO timestamp for a datetime X axis."""
|
|
246
|
+
if value is None or not str(value).strip():
|
|
247
|
+
return None
|
|
248
|
+
if datetime_axis:
|
|
249
|
+
try:
|
|
250
|
+
parsed = pd.to_datetime(value, errors="raise")
|
|
251
|
+
if not pd.isna(parsed):
|
|
252
|
+
return parsed
|
|
253
|
+
except (TypeError, ValueError, OverflowError):
|
|
254
|
+
return None
|
|
255
|
+
try:
|
|
256
|
+
number = float(value)
|
|
257
|
+
return number if np.isfinite(number) else None
|
|
258
|
+
except (TypeError, ValueError):
|
|
259
|
+
return None
|
|
260
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Column classification and default selections."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import threading
|
|
4
|
+
from datetime import date, datetime, time, timedelta
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from ..units import upas_units, lascar_units, purple_units
|
|
9
|
+
|
|
10
|
+
from .config import VARIABLE_UNITS, PLOT_TYPES, FREQUENCIES, AGGREGATES, _MPL_PALETTES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_x(data: pd.DataFrame) -> str:
|
|
14
|
+
"""Return ``time`` when available, otherwise the first column."""
|
|
15
|
+
if "datetime" in data.columns:
|
|
16
|
+
return "datetime"
|
|
17
|
+
datetime_columns = [
|
|
18
|
+
column
|
|
19
|
+
for column in data.columns
|
|
20
|
+
if pd.api.types.is_datetime64_any_dtype(data[column])
|
|
21
|
+
]
|
|
22
|
+
return str(datetime_columns[0] if datetime_columns else data.columns[0])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default_group(data: pd.DataFrame) -> str | None:
|
|
27
|
+
"""Return ``identifier`` when available."""
|
|
28
|
+
return "identifier" if "identifier" in data.columns else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _numeric_columns(data: pd.DataFrame) -> list[str]:
|
|
33
|
+
"""Return numeric column names as strings."""
|
|
34
|
+
return sorted([
|
|
35
|
+
str(column)
|
|
36
|
+
for column in data.columns
|
|
37
|
+
if pd.api.types.is_numeric_dtype(data[column])
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _categorical_columns(data: pd.DataFrame) -> list[str]:
|
|
43
|
+
"""Return categorical column names as strings."""
|
|
44
|
+
return sorted(
|
|
45
|
+
[
|
|
46
|
+
str(column)
|
|
47
|
+
for column in data.columns
|
|
48
|
+
if not pd.api.types.is_numeric_dtype(data[column])
|
|
49
|
+
and not pd.api.types.is_datetime64_any_dtype(data[column])
|
|
50
|
+
],
|
|
51
|
+
key=str.casefold,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _datetime_columns(data: pd.DataFrame) -> list[str]:
|
|
57
|
+
"""Return datetime column names as strings."""
|
|
58
|
+
return sorted(
|
|
59
|
+
[
|
|
60
|
+
str(column)
|
|
61
|
+
for column in data.columns
|
|
62
|
+
if pd.api.types.is_datetime64_any_dtype(data[column])
|
|
63
|
+
],
|
|
64
|
+
key=str.casefold,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _timedelta_columns(data: pd.DataFrame) -> list[str]:
|
|
70
|
+
"""Return timedelta column names as strings."""
|
|
71
|
+
return sorted(
|
|
72
|
+
[
|
|
73
|
+
str(column)
|
|
74
|
+
for column in data.columns
|
|
75
|
+
if pd.api.types.is_timedelta64_dtype(data[column])
|
|
76
|
+
],
|
|
77
|
+
key=str.casefold,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _alphabetical(values) -> list[str]:
|
|
83
|
+
"""Return string values sorted alphabetically, case-insensitively."""
|
|
84
|
+
return sorted((str(value) for value in values), key=str.casefold)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> list[str]:
|
|
89
|
+
"""Validate and normalise an initial Y selection."""
|
|
90
|
+
numeric = _numeric_columns(data)
|
|
91
|
+
if y is None:
|
|
92
|
+
return [column for column in numeric if column != x][:1]
|
|
93
|
+
values = [y] if isinstance(y, str) else list(y)
|
|
94
|
+
missing = [column for column in values if column not in data.columns]
|
|
95
|
+
if missing:
|
|
96
|
+
raise ValueError(f"Unknown y column(s): {missing}")
|
|
97
|
+
return [str(column) for column in values]
|
|
98
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import threading
|
|
3
|
+
from datetime import date, datetime, time, timedelta
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from ..units import upas_units, lascar_units, purple_units
|
|
8
|
+
|
|
9
|
+
VARIABLE_UNITS = {**upas_units, **lascar_units, **purple_units}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PLOT_TYPES = {
|
|
13
|
+
"bar": "Bar",
|
|
14
|
+
"box": "Boxplot",
|
|
15
|
+
"histogram": "Histogram",
|
|
16
|
+
"line": "Line",
|
|
17
|
+
"scatter": "Scatter",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
FREQUENCIES = {
|
|
22
|
+
"None": None,
|
|
23
|
+
"Day": "day",
|
|
24
|
+
"Hour": "hour",
|
|
25
|
+
"Minute": "minute",
|
|
26
|
+
"Second": "second",
|
|
27
|
+
"Week": "week",
|
|
28
|
+
"Weekday": "weekday",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
AGGREGATES = {
|
|
33
|
+
"None": None,
|
|
34
|
+
"Count": "count",
|
|
35
|
+
"Maximum": "max",
|
|
36
|
+
"Mean": "mean",
|
|
37
|
+
"Median": "median",
|
|
38
|
+
"Minimum": "min",
|
|
39
|
+
"Standard deviation": "std",
|
|
40
|
+
"Sum": "sum",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_MPL_PALETTES = {
|
|
45
|
+
"Default": ["#636EFA", "#EF553B", "#00CC96", "#AB63FA", "#FFA15A", "#19D3F3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52"],
|
|
46
|
+
"Plotly": ["#636EFA", "#EF553B", "#00CC96", "#AB63FA", "#FFA15A", "#19D3F3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52"],
|
|
47
|
+
"D3": ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"],
|
|
48
|
+
"G10": ["#3366CC", "#DC3912", "#FF9900", "#109618", "#990099", "#0099C6", "#DD4477", "#66AA00", "#B82E2E", "#316395"],
|
|
49
|
+
"T10": ["#4C78A8", "#F58518", "#E45756", "#72B7B2", "#54A24B", "#EECA3B", "#B279A2", "#FF9DA6", "#9D755D", "#BAB0AC"],
|
|
50
|
+
"Set1": ["#E41A1C", "#377EB8", "#4DAF4A", "#984EA3", "#FF7F00", "#FFFF33", "#A65628", "#F781BF", "#999999"],
|
|
51
|
+
"Set2": ["#66C2A5", "#FC8D62", "#8DA0CB", "#E78AC3", "#A6D854", "#FFD92F", "#E5C494", "#B3B3B3"],
|
|
52
|
+
"Pastel": ["#AEC6CF", "#FFB347", "#77DD77", "#CFCFC4", "#B39EB5", "#FF6961", "#FDFD96", "#CB99C9"],
|
|
53
|
+
"Dark24": ["#2E91E5", "#E15F99", "#1CA71C", "#FB0D0D", "#DA16FF", "#222A2A", "#B68100", "#750D86", "#EB663B", "#511CFB", "#00A08B", "#FB00D1", "#FC0080", "#B2828D", "#6C7C32", "#778AAE", "#862A16", "#A777F1", "#620042", "#1616A7", "#DA60CA", "#6C4516", "#0D2A63", "#AF0038"],
|
|
54
|
+
}
|
|
55
|
+
|