apmtools 0.2.54__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.54 → apmtools-0.2.56}/PKG-INFO +1 -1
- {apmtools-0.2.54 → apmtools-0.2.56}/pyproject.toml +1 -1
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/classes.py +32 -8
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/plot_app.py +80 -4
- {apmtools-0.2.54 → 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.54 → apmtools-0.2.56}/src/apmtools/shiny/matplotlib_renderer.py +13 -10
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/multi_axis.py +2 -74
- {apmtools-0.2.54 → 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.54 → apmtools-0.2.56}/src/apmtools/shiny/summary.py +3 -2
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/ui.py +430 -419
- apmtools-0.2.54/src/apmtools/shiny/appearance.py +0 -259
- apmtools-0.2.54/src/apmtools/shiny/plotly_renderer.py +0 -400
- {apmtools-0.2.54 → apmtools-0.2.56}/.gitignore +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/LICENSE +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/README.md +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/data.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.54 → apmtools-0.2.56}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.54 → 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,19 +1023,43 @@ 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
|
|
|
1042
|
+
def __setstate__(self, state):
|
|
1043
|
+
"""Restore DataFrame state and default metadata absent in older pickles."""
|
|
1044
|
+
super().__setstate__(state)
|
|
1045
|
+
# Some older pickles contain an instance-level _metadata list, which
|
|
1046
|
+
# masks the corrected class declaration and breaks future copies.
|
|
1047
|
+
self.__dict__.pop("_metadata", None)
|
|
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),
|
|
1053
|
+
("description", ""), ("metadata", set())):
|
|
1054
|
+
if name not in self.__dict__:
|
|
1055
|
+
object.__setattr__(self, name, default)
|
|
1056
|
+
|
|
1033
1057
|
def __finalize__(self, other, method=None, **kwargs):
|
|
1034
1058
|
if isinstance(other, Summary):
|
|
1035
1059
|
self.variable = getattr(other, "variable", None)
|
|
1036
1060
|
self.metadata = getattr(other, "metadata", set()).copy()
|
|
1037
1061
|
self.description = getattr(other, "description", "")
|
|
1038
|
-
self.
|
|
1062
|
+
self.views = getattr(other, "views", getattr(other, "plots", None))
|
|
1039
1063
|
|
|
1040
1064
|
return self
|
|
1041
1065
|
|
|
@@ -1065,7 +1089,7 @@ class Summary(pd.DataFrame):
|
|
|
1065
1089
|
return
|
|
1066
1090
|
|
|
1067
1091
|
return_dict = copy.deepcopy(self)
|
|
1068
|
-
return_dict.
|
|
1092
|
+
return_dict.views = None
|
|
1069
1093
|
a = {}
|
|
1070
1094
|
|
|
1071
1095
|
if filter_style not in {'all', 'any', 'negative all', 'negative any'}:
|
|
@@ -1160,7 +1184,7 @@ class Summary(pd.DataFrame):
|
|
|
1160
1184
|
self.description = self.description + \
|
|
1161
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 ""}"
|
|
1162
1186
|
result = self.reset_index()
|
|
1163
|
-
result.
|
|
1187
|
+
result.views = None
|
|
1164
1188
|
return result
|
|
1165
1189
|
|
|
1166
1190
|
def set_attrib(self, attribute):
|
|
@@ -1587,7 +1611,7 @@ class Summary(pd.DataFrame):
|
|
|
1587
1611
|
|
|
1588
1612
|
out = self.subset({"monitor": [value]})
|
|
1589
1613
|
out = out.dropna(axis=1,how="all")
|
|
1590
|
-
out.
|
|
1614
|
+
out.views = None
|
|
1591
1615
|
return out
|
|
1592
1616
|
|
|
1593
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")
|
|
@@ -83,12 +126,17 @@ class PlotApp:
|
|
|
83
126
|
import shinyswatch
|
|
84
127
|
|
|
85
128
|
app_ui = ui.page_fluid(
|
|
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"}),
|
|
86
130
|
ui.div(
|
|
87
|
-
ui.input_action_button("open_dataset", "Open Dataset", class_="btn-primary"),
|
|
131
|
+
ui.input_action_button("open_dataset", "Open Dataset", class_="btn-outline-primary"),
|
|
132
|
+
ui.input_action_button("save_dataset_root", "Save dataset", class_="btn-outline-primary"),
|
|
88
133
|
ui.output_text("dataset_status", inline=True),
|
|
89
|
-
class_="
|
|
134
|
+
ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme ms-auto"),
|
|
135
|
+
class_="d-flex align-items-center gap-1 my-0 standalone-dataset-toolbar",
|
|
90
136
|
),
|
|
91
|
-
ui.
|
|
137
|
+
ui.tags.style("""
|
|
138
|
+
body:has(#standalone-toolbar-collapse-toggle) #summary-dataset-toolbar {display:none !important;}
|
|
139
|
+
"""),
|
|
92
140
|
ui.output_ui("dataset_workspace"),
|
|
93
141
|
title="PlotApp",
|
|
94
142
|
)
|
|
@@ -110,6 +158,34 @@ class PlotApp:
|
|
|
110
158
|
def dataset_status():
|
|
111
159
|
return status.get()
|
|
112
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
|
+
|
|
113
189
|
@reactive.effect
|
|
114
190
|
@reactive.event(input.open_dataset)
|
|
115
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)
|