apmtools 0.2.55__tar.gz → 0.2.56__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.55 → apmtools-0.2.56}/PKG-INFO +1 -1
- {apmtools-0.2.55 → apmtools-0.2.56}/pyproject.toml +1 -1
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/classes.py +22 -9
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/plot_app.py +75 -13
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/plot_configs.py +66 -7
- apmtools-0.2.56/src/apmtools/shiny/appearance.py +149 -0
- apmtools-0.2.56/src/apmtools/shiny/inherited_source.py +112 -0
- apmtools-0.2.56/src/apmtools/shiny/legend_identity.py +58 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/matplotlib_renderer.py +13 -10
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/multi_axis.py +2 -74
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/multi_y.py +13 -6
- apmtools-0.2.56/src/apmtools/shiny/state.py +53 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/summary.py +3 -2
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/ui.py +307 -395
- apmtools-0.2.55/src/apmtools/shiny/appearance.py +0 -259
- apmtools-0.2.55/src/apmtools/shiny/plotly_renderer.py +0 -400
- {apmtools-0.2.55 → apmtools-0.2.56}/.gitignore +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/LICENSE +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/README.md +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/data.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.56}/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.56
|
|
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>
|
|
@@ -1011,7 +1011,7 @@ class Group(pd.DataFrame):
|
|
|
1011
1011
|
|
|
1012
1012
|
class Summary(pd.DataFrame):
|
|
1013
1013
|
|
|
1014
|
-
_metadata = ['metadata', '
|
|
1014
|
+
_metadata = ['metadata', 'views', 'variable', 'description']
|
|
1015
1015
|
|
|
1016
1016
|
@property
|
|
1017
1017
|
def _constructor(self):
|
|
@@ -1023,11 +1023,20 @@ class Summary(pd.DataFrame):
|
|
|
1023
1023
|
self.metadata = metadata
|
|
1024
1024
|
self.variable = variable
|
|
1025
1025
|
self.description = description
|
|
1026
|
-
self.
|
|
1026
|
+
self.views = None
|
|
1027
|
+
|
|
1028
|
+
@property
|
|
1029
|
+
def plots(self):
|
|
1030
|
+
"""Backward-compatible alias for saved plot and table views."""
|
|
1031
|
+
return self.views
|
|
1032
|
+
|
|
1033
|
+
@plots.setter
|
|
1034
|
+
def plots(self, value):
|
|
1035
|
+
self.views = value
|
|
1027
1036
|
|
|
1028
1037
|
def __setattr__(self, name, value):
|
|
1029
|
-
if name
|
|
1030
|
-
raise TypeError("Summary.
|
|
1038
|
+
if name in ("plots", "views") and value is not None and not isinstance(value, list):
|
|
1039
|
+
raise TypeError("Summary.views must be None or a list")
|
|
1031
1040
|
super().__setattr__(name, value)
|
|
1032
1041
|
|
|
1033
1042
|
def __setstate__(self, state):
|
|
@@ -1036,7 +1045,11 @@ class Summary(pd.DataFrame):
|
|
|
1036
1045
|
# Some older pickles contain an instance-level _metadata list, which
|
|
1037
1046
|
# masks the corrected class declaration and breaks future copies.
|
|
1038
1047
|
self.__dict__.pop("_metadata", None)
|
|
1039
|
-
|
|
1048
|
+
# Migrate saved configurations from older pickles transparently.
|
|
1049
|
+
legacy = self.__dict__.pop("plots", None)
|
|
1050
|
+
if "views" not in self.__dict__:
|
|
1051
|
+
object.__setattr__(self, "views", legacy)
|
|
1052
|
+
for name, default in (("views", None), ("variable", None),
|
|
1040
1053
|
("description", ""), ("metadata", set())):
|
|
1041
1054
|
if name not in self.__dict__:
|
|
1042
1055
|
object.__setattr__(self, name, default)
|
|
@@ -1046,7 +1059,7 @@ class Summary(pd.DataFrame):
|
|
|
1046
1059
|
self.variable = getattr(other, "variable", None)
|
|
1047
1060
|
self.metadata = getattr(other, "metadata", set()).copy()
|
|
1048
1061
|
self.description = getattr(other, "description", "")
|
|
1049
|
-
self.
|
|
1062
|
+
self.views = getattr(other, "views", getattr(other, "plots", None))
|
|
1050
1063
|
|
|
1051
1064
|
return self
|
|
1052
1065
|
|
|
@@ -1076,7 +1089,7 @@ class Summary(pd.DataFrame):
|
|
|
1076
1089
|
return
|
|
1077
1090
|
|
|
1078
1091
|
return_dict = copy.deepcopy(self)
|
|
1079
|
-
return_dict.
|
|
1092
|
+
return_dict.views = None
|
|
1080
1093
|
a = {}
|
|
1081
1094
|
|
|
1082
1095
|
if filter_style not in {'all', 'any', 'negative all', 'negative any'}:
|
|
@@ -1171,7 +1184,7 @@ class Summary(pd.DataFrame):
|
|
|
1171
1184
|
self.description = self.description + \
|
|
1172
1185
|
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 ""}"
|
|
1173
1186
|
result = self.reset_index()
|
|
1174
|
-
result.
|
|
1187
|
+
result.views = None
|
|
1175
1188
|
return result
|
|
1176
1189
|
|
|
1177
1190
|
def set_attrib(self, attribute):
|
|
@@ -1598,7 +1611,7 @@ class Summary(pd.DataFrame):
|
|
|
1598
1611
|
|
|
1599
1612
|
out = self.subset({"monitor": [value]})
|
|
1600
1613
|
out = out.dropna(axis=1,how="all")
|
|
1601
|
-
out.
|
|
1614
|
+
out.views = None
|
|
1602
1615
|
return out
|
|
1603
1616
|
|
|
1604
1617
|
|
|
@@ -8,6 +8,8 @@ from __future__ import annotations
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
import pickle
|
|
10
10
|
import threading
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
def load_summary_pickle(path):
|
|
@@ -35,6 +37,47 @@ def load_summary_pickle(path):
|
|
|
35
37
|
return data
|
|
36
38
|
|
|
37
39
|
|
|
40
|
+
def save_summary_pickle(summary, destination, *, overwrite=False):
|
|
41
|
+
"""Atomically persist the current source Summary and retained plot presets."""
|
|
42
|
+
from .classes import Summary
|
|
43
|
+
if not isinstance(summary, Summary):
|
|
44
|
+
raise TypeError("No Summary dataset is loaded")
|
|
45
|
+
target = Path(destination).expanduser().resolve()
|
|
46
|
+
if target.suffix.lower() != ".pkl":
|
|
47
|
+
raise ValueError("The filename must end in .pkl")
|
|
48
|
+
if not target.parent.is_dir():
|
|
49
|
+
raise FileNotFoundError(f"Directory does not exist: {target.parent}")
|
|
50
|
+
if target.exists() and not overwrite:
|
|
51
|
+
raise FileExistsError(f"File already exists: {target}")
|
|
52
|
+
if target.exists() and not target.is_file():
|
|
53
|
+
raise ValueError(f"Destination is not a regular file: {target}")
|
|
54
|
+
from .plot_configs import validate_saved_plots
|
|
55
|
+
retained = validate_saved_plots(getattr(summary, "plots", None))
|
|
56
|
+
temporary = None
|
|
57
|
+
try:
|
|
58
|
+
with tempfile.NamedTemporaryFile(mode="wb", dir=target.parent,
|
|
59
|
+
prefix=".apmtools-", suffix=".tmp",
|
|
60
|
+
delete=False) as stream:
|
|
61
|
+
temporary = Path(stream.name)
|
|
62
|
+
snapshot = summary.copy(deep=False)
|
|
63
|
+
snapshot.plots = retained
|
|
64
|
+
pickle.dump(snapshot, stream, protocol=pickle.HIGHEST_PROTOCOL)
|
|
65
|
+
stream.flush()
|
|
66
|
+
os.fsync(stream.fileno())
|
|
67
|
+
with temporary.open("rb") as stream:
|
|
68
|
+
restored = pickle.load(stream)
|
|
69
|
+
if getattr(restored, "plots", None) != retained:
|
|
70
|
+
raise RuntimeError("Saved plot configurations failed pickle verification")
|
|
71
|
+
if overwrite:
|
|
72
|
+
os.replace(temporary, target)
|
|
73
|
+
else:
|
|
74
|
+
os.link(temporary, target)
|
|
75
|
+
finally:
|
|
76
|
+
if temporary is not None:
|
|
77
|
+
temporary.unlink(missing_ok=True)
|
|
78
|
+
return target
|
|
79
|
+
|
|
80
|
+
|
|
38
81
|
class PlotApp:
|
|
39
82
|
"""Launch a Shiny plot workspace with an optional initial Summary.
|
|
40
83
|
|
|
@@ -45,7 +88,7 @@ class PlotApp:
|
|
|
45
88
|
"""
|
|
46
89
|
|
|
47
90
|
def __init__(self, summary=None, *, x=None, y=None, group_by=None,
|
|
48
|
-
plot_type="line", title="Summary plot", backend="
|
|
91
|
+
plot_type="line", title="Summary plot", backend="matplotlib"):
|
|
49
92
|
from .classes import Summary
|
|
50
93
|
if summary is not None and not isinstance(summary, Summary):
|
|
51
94
|
raise TypeError("PlotApp expects an apmtools Summary or None")
|
|
@@ -86,22 +129,13 @@ class PlotApp:
|
|
|
86
129
|
ui.tags.button("Hide toolbars", type="button", class_="btn btn-sm btn-outline-secondary my-0", id="standalone-toolbar-collapse-toggle", **{"aria-expanded": "true"}),
|
|
87
130
|
ui.div(
|
|
88
131
|
ui.input_action_button("open_dataset", "Open Dataset", class_="btn-outline-primary"),
|
|
89
|
-
ui.
|
|
132
|
+
ui.input_action_button("save_dataset_root", "Save dataset", class_="btn-outline-primary"),
|
|
90
133
|
ui.output_text("dataset_status", inline=True),
|
|
91
134
|
ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme ms-auto"),
|
|
92
135
|
class_="d-flex align-items-center gap-1 my-0 standalone-dataset-toolbar",
|
|
93
136
|
),
|
|
94
|
-
ui.tags.
|
|
95
|
-
(
|
|
96
|
-
const move = () => {
|
|
97
|
-
const slot = document.getElementById('standalone-save-dataset-slot');
|
|
98
|
-
const button = document.querySelector('#summary-dataset-toolbar button');
|
|
99
|
-
if (slot && button && button.parentElement !== slot) slot.appendChild(button);
|
|
100
|
-
};
|
|
101
|
-
new MutationObserver(move).observe(document.body, {childList:true, subtree:true});
|
|
102
|
-
document.addEventListener('DOMContentLoaded', move);
|
|
103
|
-
move();
|
|
104
|
-
})();
|
|
137
|
+
ui.tags.style("""
|
|
138
|
+
body:has(#standalone-toolbar-collapse-toggle) #summary-dataset-toolbar {display:none !important;}
|
|
105
139
|
"""),
|
|
106
140
|
ui.output_ui("dataset_workspace"),
|
|
107
141
|
title="PlotApp",
|
|
@@ -124,6 +158,34 @@ class PlotApp:
|
|
|
124
158
|
def dataset_status():
|
|
125
159
|
return status.get()
|
|
126
160
|
|
|
161
|
+
@reactive.effect
|
|
162
|
+
@reactive.event(input.save_dataset_root)
|
|
163
|
+
def show_root_save_dialog():
|
|
164
|
+
if self.original_summary is None:
|
|
165
|
+
ui.notification_show("Open a dataset before saving", type="warning")
|
|
166
|
+
return
|
|
167
|
+
ui.modal_show(ui.modal(
|
|
168
|
+
ui.p("Save the loaded Summary and its saved plot configurations as a pickle."),
|
|
169
|
+
ui.input_text("root_dataset_path", "Destination path on the computer running Shiny",
|
|
170
|
+
value="summary.pkl"),
|
|
171
|
+
ui.input_checkbox("root_dataset_overwrite", "Overwrite existing file", value=False),
|
|
172
|
+
title="Save dataset",
|
|
173
|
+
footer=ui.div(ui.modal_button("Cancel"),
|
|
174
|
+
ui.input_action_button("root_confirm_save", "Save pickle", class_="btn-primary")),
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
@reactive.effect
|
|
178
|
+
@reactive.event(input.root_confirm_save)
|
|
179
|
+
def save_root_dataset():
|
|
180
|
+
try:
|
|
181
|
+
target = save_summary_pickle(
|
|
182
|
+
self.original_summary, input.root_dataset_path(),
|
|
183
|
+
overwrite=bool(input.root_dataset_overwrite()))
|
|
184
|
+
ui.modal_remove()
|
|
185
|
+
ui.notification_show(f"Dataset saved to {target}", type="message", duration=8)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
ui.notification_show(f"Could not save dataset: {exc}", type="error", duration=10)
|
|
188
|
+
|
|
127
189
|
@reactive.effect
|
|
128
190
|
@reactive.event(input.open_dataset)
|
|
129
191
|
def show_dataset_dialog():
|
|
@@ -10,6 +10,30 @@ def _json_ready(value):
|
|
|
10
10
|
return json.loads(json.dumps(value, allow_nan=False))
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
|
|
14
|
+
def validate_saved_plots(plots):
|
|
15
|
+
"""Validate the structure of retained presets without altering their values.
|
|
16
|
+
|
|
17
|
+
Old snapshots remain supported: unknown settings are deliberately retained.
|
|
18
|
+
"""
|
|
19
|
+
if plots is None:
|
|
20
|
+
return []
|
|
21
|
+
if not isinstance(plots, list):
|
|
22
|
+
raise TypeError("Summary.views must be a list or None")
|
|
23
|
+
seen = set()
|
|
24
|
+
for item in plots:
|
|
25
|
+
if not isinstance(item, dict):
|
|
26
|
+
raise ValueError("Saved plot entries must be dictionaries")
|
|
27
|
+
if item.get("schema_version") != 1 or item.get("type") not in ("plot", "table"):
|
|
28
|
+
raise ValueError("Unsupported saved plot configuration")
|
|
29
|
+
if not isinstance(item.get("settings"), dict):
|
|
30
|
+
raise ValueError("Saved plot settings must be a dictionary")
|
|
31
|
+
identifier = item.get("id")
|
|
32
|
+
if not isinstance(identifier, str) or not identifier or identifier in seen:
|
|
33
|
+
raise ValueError("Saved plot IDs must be unique nonempty strings")
|
|
34
|
+
seen.add(identifier)
|
|
35
|
+
return copy.deepcopy(plots)
|
|
36
|
+
|
|
13
37
|
class PlotConfigManager:
|
|
14
38
|
"""Keep live tab drafts separate from explicitly retained Summary.plots.
|
|
15
39
|
|
|
@@ -26,7 +50,7 @@ class PlotConfigManager:
|
|
|
26
50
|
if getattr(summary, 'plots', None) is None:
|
|
27
51
|
summary.plots = []
|
|
28
52
|
if not isinstance(summary.plots, list):
|
|
29
|
-
raise TypeError('Summary.
|
|
53
|
+
raise TypeError('Summary.views must be a list or None')
|
|
30
54
|
|
|
31
55
|
# Settings that affect filtering, grouping or aggregation. Everything else
|
|
32
56
|
# is treated as presentation state. Panel layout (panel_by) is
|
|
@@ -119,28 +143,63 @@ class PlotConfigManager:
|
|
|
119
143
|
preset_id = preset_id or uuid.uuid4().hex
|
|
120
144
|
item = {'schema_version': 1, 'id': preset_id, 'name': name,
|
|
121
145
|
'type': 'plot', 'settings': self.get(tab_id)}
|
|
122
|
-
items = copy.deepcopy(self.summary.
|
|
146
|
+
items = copy.deepcopy(self.summary.views or [])
|
|
123
147
|
for i, old in enumerate(items):
|
|
124
148
|
if old.get('id') == preset_id:
|
|
125
149
|
items[i] = item
|
|
126
150
|
break
|
|
127
151
|
else:
|
|
128
152
|
items.append(item)
|
|
129
|
-
self.summary.
|
|
153
|
+
self.summary.views = items
|
|
130
154
|
self.revision += 1
|
|
131
155
|
return preset_id
|
|
132
156
|
|
|
157
|
+
def retain_table(self, name, settings, frame=None, *, preset_id=None):
|
|
158
|
+
"""Save table settings only; data are sourced from the owning Summary.
|
|
159
|
+
|
|
160
|
+
``frame`` is accepted for compatibility with callers of the older API,
|
|
161
|
+
but is never serialized. Legacy table snapshots are migrated on save.
|
|
162
|
+
"""
|
|
163
|
+
name = str(name).strip()
|
|
164
|
+
if not name:
|
|
165
|
+
raise ValueError('Configuration name must not be empty')
|
|
166
|
+
preset_id = preset_id or uuid.uuid4().hex
|
|
167
|
+
item = {'schema_version': 1, 'id': preset_id, 'name': name,
|
|
168
|
+
'type': 'table', 'settings': _json_ready(settings)}
|
|
169
|
+
items = self.saved()
|
|
170
|
+
for i, old in enumerate(items):
|
|
171
|
+
if old.get('id') == preset_id:
|
|
172
|
+
items[i] = item
|
|
173
|
+
break
|
|
174
|
+
else:
|
|
175
|
+
items.append(item)
|
|
176
|
+
self.summary.views = items
|
|
177
|
+
self.revision += 1
|
|
178
|
+
return preset_id
|
|
179
|
+
|
|
180
|
+
def restore_table(self, preset_id):
|
|
181
|
+
item = next((p for p in self.saved() if p.get('id') == preset_id), None)
|
|
182
|
+
if item is None or item.get('type') != 'table':
|
|
183
|
+
raise KeyError(preset_id)
|
|
184
|
+
# Legacy frame snapshots are intentionally ignored. All tables now
|
|
185
|
+
# resolve against the current owning dataset, not a frozen copy.
|
|
186
|
+
return self.summary.copy(deep=False), copy.deepcopy(item['settings'])
|
|
187
|
+
|
|
133
188
|
def delete_saved(self, preset_id):
|
|
134
189
|
"""Delete one explicitly saved plot by stable ID, not its open draft."""
|
|
135
|
-
items = copy.deepcopy(self.summary.
|
|
136
|
-
remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type')
|
|
190
|
+
items = copy.deepcopy(self.summary.views or [])
|
|
191
|
+
remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') in ('plot', 'table'))]
|
|
137
192
|
if len(remaining) == len(items):
|
|
138
193
|
raise KeyError(f'Unknown saved plot: {preset_id}')
|
|
139
|
-
self.summary.
|
|
194
|
+
self.summary.views = remaining
|
|
140
195
|
self.revision += 1
|
|
141
196
|
|
|
142
197
|
def saved(self):
|
|
143
|
-
|
|
198
|
+
items = validate_saved_plots(self.summary.views)
|
|
199
|
+
for item in items:
|
|
200
|
+
if item.get('type') == 'table':
|
|
201
|
+
item.pop('frame', None) # migrate legacy snapshot records
|
|
202
|
+
return items
|
|
144
203
|
|
|
145
204
|
def restore(self, preset_id, tab_id):
|
|
146
205
|
item = next((p for p in self.saved() if p.get('id') == preset_id), None)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Figure labels and appearance controls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
import threading
|
|
5
|
+
from datetime import date, datetime, time, timedelta
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from ..units import upas_units, lascar_units, purple_units
|
|
10
|
+
|
|
11
|
+
from .config import VARIABLE_UNITS, PLOT_TYPES, FREQUENCIES, AGGREGATES, _MPL_PALETTES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _axis_label(variable: str) -> str:
|
|
15
|
+
"""Return a variable label with units, including aggregated columns.
|
|
16
|
+
|
|
17
|
+
Aggregation produces names such as ``pm25_mean`` or ``CO(ppm)_max``.
|
|
18
|
+
Strip only a *recognized* aggregation suffix, and only when the full
|
|
19
|
+
column name does not already have its own units entry.
|
|
20
|
+
"""
|
|
21
|
+
name = str(variable)
|
|
22
|
+
unit = VARIABLE_UNITS.get(name)
|
|
23
|
+
if unit is None:
|
|
24
|
+
for suffix in ("count", "max", "mean", "median", "min", "std", "sum"):
|
|
25
|
+
marker = f"_{suffix}"
|
|
26
|
+
if name.endswith(marker):
|
|
27
|
+
unit = VARIABLE_UNITS.get(name[:-len(marker)])
|
|
28
|
+
if unit is not None:
|
|
29
|
+
break
|
|
30
|
+
return f"{name} [{unit}]" if unit else name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
|
|
35
|
+
category_columns=None, x_label_override=None):
|
|
36
|
+
"""Label plot axes while retaining special histogram and facet labels."""
|
|
37
|
+
if plot_type == "histogram":
|
|
38
|
+
fig.update_xaxes(title_text=_axis_label(x_column))
|
|
39
|
+
elif plot_type in {"box", "bar"}:
|
|
40
|
+
categories = category_columns or [x_column]
|
|
41
|
+
fig.update_xaxes(title_text=" | ".join(_axis_label(c) for c in categories))
|
|
42
|
+
fig.update_yaxes(title_text="<br>".join(_axis_label(y) for y in y_columns))
|
|
43
|
+
elif plot_type == "line" and len(y_columns) > 1:
|
|
44
|
+
fig.update_xaxes(title_text=_axis_label(x_column))
|
|
45
|
+
fig.update_yaxes(title_text="<br>".join(_axis_label(y) for y in y_columns))
|
|
46
|
+
for annotation in fig.layout.annotations:
|
|
47
|
+
label = str(annotation.text)
|
|
48
|
+
if label.startswith("variable="):
|
|
49
|
+
annotation.text = _axis_label(label.split("=", 1)[1])
|
|
50
|
+
else:
|
|
51
|
+
fig.update_xaxes(title_text=_axis_label(x_column))
|
|
52
|
+
if y_columns:
|
|
53
|
+
fig.update_yaxes(title_text=_axis_label(y_columns[0]))
|
|
54
|
+
if x_label_override is not None:
|
|
55
|
+
fig.update_xaxes(title_text=x_label_override)
|
|
56
|
+
return fig
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_label_map(value):
|
|
61
|
+
"""Parse semicolon-separated original=replacement entries."""
|
|
62
|
+
result = {}
|
|
63
|
+
for entry in re.split(r"[;\r\n]+", value or ""):
|
|
64
|
+
if "=" in entry:
|
|
65
|
+
key, replacement = entry.split("=", 1)
|
|
66
|
+
if key.strip() and replacement.strip():
|
|
67
|
+
result[key.strip()] = replacement.strip()
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _numeric_axis_controls(kind, box_orientation="vertical"):
|
|
73
|
+
"""Return axes on which numeric/date tick-density controls apply."""
|
|
74
|
+
if kind in ("line", "scatter", "histogram"):
|
|
75
|
+
return True, True
|
|
76
|
+
if kind == "box" and box_orientation == "horizontal":
|
|
77
|
+
return True, False
|
|
78
|
+
return False, kind in ("box", "bar")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _matplotlib_tick_controls(ax, *, kind, box_orientation="vertical", show_grid=True,
|
|
83
|
+
x_major_ticks=6, y_major_ticks=6,
|
|
84
|
+
x_minor_ticks=False, y_minor_ticks=False):
|
|
85
|
+
from matplotlib.ticker import MaxNLocator, AutoMinorLocator
|
|
86
|
+
from matplotlib.dates import AutoDateLocator
|
|
87
|
+
use_x, use_y = _numeric_axis_controls(kind, box_orientation)
|
|
88
|
+
for active, axis, count, minor in ((use_x, ax.xaxis, x_major_ticks, x_minor_ticks),
|
|
89
|
+
(use_y, ax.yaxis, y_major_ticks, y_minor_ticks)):
|
|
90
|
+
if not active:
|
|
91
|
+
continue
|
|
92
|
+
count = max(2, min(30, int(count or 6)))
|
|
93
|
+
if axis.get_scale() == "linear":
|
|
94
|
+
if isinstance(axis.get_major_locator(), AutoDateLocator):
|
|
95
|
+
axis.set_major_locator(AutoDateLocator(minticks=max(2, count - 2), maxticks=count))
|
|
96
|
+
else:
|
|
97
|
+
axis.set_major_locator(MaxNLocator(nbins=count))
|
|
98
|
+
if minor:
|
|
99
|
+
axis.set_minor_locator(AutoMinorLocator())
|
|
100
|
+
else:
|
|
101
|
+
axis.set_minor_locator(__import__("matplotlib.ticker", fromlist=["NullLocator"]).NullLocator())
|
|
102
|
+
axis.grid(bool(show_grid and minor), which="minor")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _series_style_maps(line_alpha="", line_style="", scatter_size="", scatter_marker="", scatter_alpha=""):
|
|
107
|
+
"""Parse per-trace styles, keyed by the original legend/series name."""
|
|
108
|
+
return tuple(_parse_label_map(v) for v in (line_alpha, line_style, scatter_size, scatter_marker, scatter_alpha))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _bounded_float(mapping, name, default, low, high):
|
|
113
|
+
try:
|
|
114
|
+
value = float(mapping.get(name, default))
|
|
115
|
+
return value if np.isfinite(value) and low <= value <= high else float(default)
|
|
116
|
+
except (ValueError, TypeError):
|
|
117
|
+
return float(default)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _line_style(mapping, name):
|
|
122
|
+
return {"solid": "-", "dotted": ":", "dashed": "--", "-": "-", ":": ":", "--": "--"}.get(
|
|
123
|
+
mapping.get(name, "solid").strip().lower(), "-")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _scatter_marker(mapping, name):
|
|
128
|
+
return {"dot": "o", "cross": "x", "o": "o", "x": "x"}.get(
|
|
129
|
+
mapping.get(name, "dot").strip().lower(), "o")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_axis_limit(value, *, datetime_axis=False):
|
|
134
|
+
"""Parse numeric bounds or an ISO timestamp for a datetime X axis."""
|
|
135
|
+
if value is None or not str(value).strip():
|
|
136
|
+
return None
|
|
137
|
+
if datetime_axis:
|
|
138
|
+
try:
|
|
139
|
+
parsed = pd.to_datetime(value, errors="raise")
|
|
140
|
+
if not pd.isna(parsed):
|
|
141
|
+
return parsed
|
|
142
|
+
except (TypeError, ValueError, OverflowError):
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
number = float(value)
|
|
146
|
+
return number if np.isfinite(number) else None
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
return None
|
|
149
|
+
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Replay configuration-only source transformations for derived tables."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import copy
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import numpy as np
|
|
6
|
+
from .data import _subset_choices, _apply_filters, _aggregate
|
|
7
|
+
from .config import AGGREGATES, FREQUENCIES
|
|
8
|
+
from .columns import _numeric_columns
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _plot_columns(current, step):
|
|
12
|
+
"""Resolve the plot's source columns without guessing aggregation suffixes."""
|
|
13
|
+
kind = step.get('plot_type', 'line')
|
|
14
|
+
x = step.get('x') or []
|
|
15
|
+
y = step.get('y') or []
|
|
16
|
+
x = [x] if isinstance(x, str) else list(x)
|
|
17
|
+
y = [y] if isinstance(y, str) else list(y)
|
|
18
|
+
values = x if kind == 'histogram' else y
|
|
19
|
+
values = [str(v) for v in values if str(v) in current.columns]
|
|
20
|
+
groups = step.get('group_by') or []
|
|
21
|
+
groups = [groups] if isinstance(groups, str) else list(groups)
|
|
22
|
+
groups = [str(g) for g in groups if g not in ('variables_x', 'variables_y') and str(g) in current.columns]
|
|
23
|
+
x_columns = [str(v) for v in x if str(v) in current.columns]
|
|
24
|
+
if kind in ('bar', 'box') and AGGREGATES.get(step.get('aggregate')) is not None:
|
|
25
|
+
groups = list(dict.fromkeys(groups + x_columns))
|
|
26
|
+
if kind == 'scatter' and AGGREGATES.get(step.get('aggregate')) is not None:
|
|
27
|
+
values = list(dict.fromkeys(x_columns + values))
|
|
28
|
+
return kind, values, groups, x_columns
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _plot_source(current, step):
|
|
32
|
+
"""Reconstruct the data contributing to a plot, with its exact aggregation."""
|
|
33
|
+
kind, values, groups, x_columns = _plot_columns(current, step)
|
|
34
|
+
if not values:
|
|
35
|
+
# Legacy plot steps did not capture axis selections.
|
|
36
|
+
return current
|
|
37
|
+
# Exclude observations that cannot contribute to any selected outcome.
|
|
38
|
+
current = current.loc[current[values].notna().any(axis=1)].copy()
|
|
39
|
+
if current.empty:
|
|
40
|
+
return current
|
|
41
|
+
aggregate = AGGREGATES.get(step.get('aggregate'))
|
|
42
|
+
frequency = FREQUENCIES.get(step.get('frequency'))
|
|
43
|
+
if aggregate is not None:
|
|
44
|
+
if kind == 'line' and x_columns and pd.api.types.is_timedelta64_dtype(current[x_columns[0]]):
|
|
45
|
+
return pd.DataFrame()
|
|
46
|
+
if frequency is None and not groups:
|
|
47
|
+
return pd.DataFrame()
|
|
48
|
+
result = _aggregate(current, values, frequency, groups, aggregate,
|
|
49
|
+
only_complete=bool(step.get('only_complete', True)) if frequency is not None else True,
|
|
50
|
+
flooring_timestamp=bool(step.get('flooring_timestamp', True)) if frequency is not None else True)
|
|
51
|
+
if result is None or result.empty:
|
|
52
|
+
return pd.DataFrame()
|
|
53
|
+
# group_stat already emits the grouping and frequency columns.
|
|
54
|
+
return result
|
|
55
|
+
keep = list(dict.fromkeys(x_columns + groups + values))
|
|
56
|
+
if 'datetime' in current.columns and 'datetime' not in keep:
|
|
57
|
+
keep.insert(0, 'datetime')
|
|
58
|
+
return current.loc[:, keep].copy()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def source_frame(summary, steps):
|
|
62
|
+
"""Rebuild a table source from its owning Summary and ordered settings.
|
|
63
|
+
|
|
64
|
+
Each step is a configuration snapshot, never a DataFrame snapshot.
|
|
65
|
+
"""
|
|
66
|
+
current = summary.copy(deep=False)
|
|
67
|
+
for step in steps or []:
|
|
68
|
+
for item in step.get('subset_chain', []):
|
|
69
|
+
name = item.get('column')
|
|
70
|
+
if name not in current.columns:
|
|
71
|
+
raise ValueError(f'Inherited source column is unavailable: {name}')
|
|
72
|
+
_, mapping = _subset_choices(current, name)
|
|
73
|
+
selected = [mapping[k] for k in item.get('values', []) if k in mapping]
|
|
74
|
+
if selected:
|
|
75
|
+
current = current.subset({name: selected}, filter_style=item.get('style', 'all'))
|
|
76
|
+
current = _apply_filters(current, {}, step.get('datetime_start'), step.get('datetime_end'))
|
|
77
|
+
for name in step.get('numeric_subset_columns', []):
|
|
78
|
+
if name not in current.columns:
|
|
79
|
+
raise ValueError(f'Inherited numeric column is unavailable: {name}')
|
|
80
|
+
bounds = step.get('numeric_subset_bounds', {}).get(name, {})
|
|
81
|
+
for op in ('gt', 'lt'):
|
|
82
|
+
try:
|
|
83
|
+
threshold = float(bounds.get(op, ''))
|
|
84
|
+
except (ValueError, TypeError):
|
|
85
|
+
continue
|
|
86
|
+
if np.isfinite(threshold):
|
|
87
|
+
values = pd.to_numeric(current[name], errors='coerce')
|
|
88
|
+
matches = values.gt(threshold) if op == 'gt' else values.lt(threshold)
|
|
89
|
+
current = current.loc[matches.fillna(False) | values.isna()].copy()
|
|
90
|
+
# Both plot and table transformations are replayed from the source.
|
|
91
|
+
# A plot-derived table must contain the actual plotted measurements,
|
|
92
|
+
# not all numeric columns from the unprocessed Summary.
|
|
93
|
+
if step.get('_source_kind') == 'plot':
|
|
94
|
+
current = _plot_source(current, step)
|
|
95
|
+
elif step.get('_source_kind') == 'table':
|
|
96
|
+
aggregate = AGGREGATES.get(step.get('aggregate'))
|
|
97
|
+
if aggregate is not None and not current.empty:
|
|
98
|
+
frequency = FREQUENCIES.get(step.get('frequency'))
|
|
99
|
+
groups = list(step.get('group_by') or [])
|
|
100
|
+
numeric = [c for c in _numeric_columns(current) if c not in groups and current[c].notna().any()]
|
|
101
|
+
if numeric:
|
|
102
|
+
current = _aggregate(current, numeric, frequency, groups, aggregate,
|
|
103
|
+
only_complete=bool(step.get('only_complete', True)) if frequency is not None else False,
|
|
104
|
+
flooring_timestamp=bool(step.get('flooring_timestamp', True)))
|
|
105
|
+
current = summary._constructor(current) if not hasattr(current, 'subset') else current
|
|
106
|
+
return current
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def source_steps(parent_steps, parent_settings, kind):
|
|
110
|
+
step = copy.deepcopy(parent_settings)
|
|
111
|
+
step['_source_kind'] = kind
|
|
112
|
+
return copy.deepcopy(parent_steps or []) + [step]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Legend identities based on original columns, not inferred name suffixes."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def variable_aliases(source_variables=(), aggregate=None):
|
|
5
|
+
"""Return processed-column -> source-column identities from plot metadata.
|
|
6
|
+
|
|
7
|
+
Never strip suffixes from an arbitrary label: a source column may itself
|
|
8
|
+
legitimately be named ``temperature_mean``.
|
|
9
|
+
"""
|
|
10
|
+
return {f"{source}_{aggregate}": str(source) for source in source_variables
|
|
11
|
+
if aggregate is not None and str(source)}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def canonical_legend_key(key, aliases=None):
|
|
15
|
+
return (aliases or {}).get(str(key), str(key))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def canonical_legend_map(mapping, preferred_suffix=None, aliases=None):
|
|
19
|
+
"""Consolidate known aliases while preserving unrelated group identities.
|
|
20
|
+
|
|
21
|
+
``preferred_suffix`` is retained for compatibility with older callers;
|
|
22
|
+
canonicalization requires explicit aliases to avoid guessing from names.
|
|
23
|
+
"""
|
|
24
|
+
aliases = aliases or {}
|
|
25
|
+
groups = {}
|
|
26
|
+
for key, value in mapping.items():
|
|
27
|
+
base = canonical_legend_key(key, aliases)
|
|
28
|
+
groups.setdefault(base, []).append((str(key), value))
|
|
29
|
+
result = {}
|
|
30
|
+
for base, entries in groups.items():
|
|
31
|
+
preferred = next((value for key, value in entries if key in aliases), None)
|
|
32
|
+
direct = next((value for key, value in entries if key == base), None)
|
|
33
|
+
chosen = preferred if preferred is not None else direct if direct is not None else entries[0][1]
|
|
34
|
+
if chosen != base:
|
|
35
|
+
result[base] = chosen
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def legend_label(mapping, key, aliases=None):
|
|
40
|
+
key = str(key)
|
|
41
|
+
return mapping.get(canonical_legend_key(key, aliases), mapping.get(key, key))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def original_legend_keys(displayed, mapping, known_keys=(), preferred_suffix=None, aliases=None):
|
|
45
|
+
mapping = canonical_legend_map(mapping, aliases=aliases)
|
|
46
|
+
reverse = {}
|
|
47
|
+
for original, replacement in mapping.items():
|
|
48
|
+
reverse.setdefault(replacement, set()).add(original)
|
|
49
|
+
for original in known_keys:
|
|
50
|
+
base = canonical_legend_key(original, aliases)
|
|
51
|
+
reverse.setdefault(mapping.get(base, base), set()).add(base)
|
|
52
|
+
keys = []
|
|
53
|
+
for label in displayed:
|
|
54
|
+
matches = reverse.get(label, set())
|
|
55
|
+
original = next(iter(matches)) if len(matches) == 1 else label
|
|
56
|
+
if original not in keys:
|
|
57
|
+
keys.append(original)
|
|
58
|
+
return tuple(keys)
|