apmtools 0.2.55__tar.gz → 0.2.57__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.57}/PKG-INFO +1 -1
- {apmtools-0.2.55 → apmtools-0.2.57}/pyproject.toml +1 -1
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/classes.py +41 -41
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/plot_app.py +75 -13
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/plot_configs.py +47 -7
- apmtools-0.2.57/src/apmtools/shiny/appearance.py +149 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/data.py +7 -7
- apmtools-0.2.57/src/apmtools/shiny/derived_views.py +35 -0
- apmtools-0.2.57/src/apmtools/shiny/inherited_source.py +132 -0
- apmtools-0.2.57/src/apmtools/shiny/legend_identity.py +58 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/matplotlib_renderer.py +26 -18
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/multi_axis.py +2 -74
- apmtools-0.2.57/src/apmtools/shiny/multi_stat_plot.py +53 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/multi_y.py +13 -6
- apmtools-0.2.57/src/apmtools/shiny/profile_report.py +51 -0
- apmtools-0.2.57/src/apmtools/shiny/profiling.py +103 -0
- apmtools-0.2.57/src/apmtools/shiny/state.py +67 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/summary.py +3 -2
- apmtools-0.2.57/src/apmtools/shiny/tab_initialization.py +74 -0
- apmtools-0.2.57/src/apmtools/shiny/text_editors.py +23 -0
- apmtools-0.2.57/src/apmtools/shiny/transformations.py +208 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/ui.py +719 -552
- apmtools-0.2.57/src/apmtools/shiny/view_aggregation.py +124 -0
- apmtools-0.2.57/src/apmtools/shiny/view_controller.py +91 -0
- apmtools-0.2.57/src/apmtools/shiny/view_history.py +106 -0
- apmtools-0.2.57/src/apmtools/shiny/workspace_controller.py +58 -0
- apmtools-0.2.57/src/apmtools/view_schema.py +106 -0
- 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.57}/.gitignore +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/LICENSE +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/README.md +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.55 → apmtools-0.2.57}/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.57
|
|
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):
|
|
@@ -1211,7 +1224,12 @@ class Summary(pd.DataFrame):
|
|
|
1211
1224
|
print(f"{v} not in Summary columns")
|
|
1212
1225
|
return None
|
|
1213
1226
|
|
|
1214
|
-
|
|
1227
|
+
# Non-temporal grouping also works on previously aggregated frames.
|
|
1228
|
+
# Only operations involving a frequency need the source time column.
|
|
1229
|
+
if frequency is not None and time_col not in self.columns:
|
|
1230
|
+
raise KeyError(f"Temporal aggregation requires column {time_col!r}")
|
|
1231
|
+
df = (self.dropna(subset=[time_col]).copy()
|
|
1232
|
+
if frequency is not None else self.copy())
|
|
1215
1233
|
|
|
1216
1234
|
if frequency != None:
|
|
1217
1235
|
|
|
@@ -1542,38 +1560,20 @@ class Summary(pd.DataFrame):
|
|
|
1542
1560
|
df[freq] = df["datetime"].map(
|
|
1543
1561
|
lambda x: (x.weekday//mult))*mult
|
|
1544
1562
|
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
if
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
else:
|
|
1559
|
-
output = df
|
|
1563
|
+
# A frequency may also be an explicitly selected grouping variable.
|
|
1564
|
+
# pandas allows repeated group keys but reset_index() then attempts to
|
|
1565
|
+
# insert the same column twice (e.g. "cannot insert hour, already exists").
|
|
1566
|
+
# Preserve the requested order while grouping each key only once.
|
|
1567
|
+
grouping_keys = list(dict.fromkeys(
|
|
1568
|
+
list(grouping_by or []) +
|
|
1569
|
+
([frequency] if isinstance(frequency, str) else
|
|
1570
|
+
list(frequency) if isinstance(frequency, list) else [])
|
|
1571
|
+
))
|
|
1572
|
+
if frequency is not None or grouping_by is not None:
|
|
1573
|
+
output = df.groupby(grouping_keys, dropna=False) if aggregate is not None else df[grouping_keys]
|
|
1574
|
+
else:
|
|
1575
|
+
output = df
|
|
1560
1576
|
|
|
1561
|
-
if isinstance(frequency, str) and aggregate is None:
|
|
1562
|
-
if grouping_by != None:
|
|
1563
|
-
output = df[grouping_by+[frequency]]
|
|
1564
|
-
else:
|
|
1565
|
-
output = df[[frequency]]
|
|
1566
|
-
if isinstance(frequency, list) and aggregate is None:
|
|
1567
|
-
if grouping_by != None:
|
|
1568
|
-
output = df[grouping_by+frequency]
|
|
1569
|
-
else:
|
|
1570
|
-
output = df[frequency]
|
|
1571
|
-
if frequency == None and aggregate is None:
|
|
1572
|
-
if grouping_by != None:
|
|
1573
|
-
output = df[grouping_by]
|
|
1574
|
-
else:
|
|
1575
|
-
output = df
|
|
1576
|
-
|
|
1577
1577
|
if aggregate is not None:
|
|
1578
1578
|
output = output[value_cols].agg(aggregate)
|
|
1579
1579
|
|
|
@@ -1598,7 +1598,7 @@ class Summary(pd.DataFrame):
|
|
|
1598
1598
|
|
|
1599
1599
|
out = self.subset({"monitor": [value]})
|
|
1600
1600
|
out = out.dropna(axis=1,how="all")
|
|
1601
|
-
out.
|
|
1601
|
+
out.views = None
|
|
1602
1602
|
return out
|
|
1603
1603
|
|
|
1604
1604
|
|
|
@@ -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():
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Plot configuration lifecycle: reactive drafts and explicitly retained presets."""
|
|
2
2
|
from __future__ import annotations
|
|
3
|
+
from .shiny.profiling import traced
|
|
3
4
|
import copy
|
|
4
5
|
import json
|
|
5
6
|
import uuid
|
|
@@ -10,6 +11,13 @@ def _json_ready(value):
|
|
|
10
11
|
return json.loads(json.dumps(value, allow_nan=False))
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
|
|
15
|
+
def validate_saved_plots(plots):
|
|
16
|
+
"""Backward-compatible name for strict saved-view structural validation."""
|
|
17
|
+
from .view_schema import validate_saved_views
|
|
18
|
+
return validate_saved_views(plots)
|
|
19
|
+
|
|
20
|
+
|
|
13
21
|
class PlotConfigManager:
|
|
14
22
|
"""Keep live tab drafts separate from explicitly retained Summary.plots.
|
|
15
23
|
|
|
@@ -26,7 +34,7 @@ class PlotConfigManager:
|
|
|
26
34
|
if getattr(summary, 'plots', None) is None:
|
|
27
35
|
summary.plots = []
|
|
28
36
|
if not isinstance(summary.plots, list):
|
|
29
|
-
raise TypeError('Summary.
|
|
37
|
+
raise TypeError('Summary.views must be a list or None')
|
|
30
38
|
|
|
31
39
|
# Settings that affect filtering, grouping or aggregation. Everything else
|
|
32
40
|
# is treated as presentation state. Panel layout (panel_by) is
|
|
@@ -119,28 +127,60 @@ class PlotConfigManager:
|
|
|
119
127
|
preset_id = preset_id or uuid.uuid4().hex
|
|
120
128
|
item = {'schema_version': 1, 'id': preset_id, 'name': name,
|
|
121
129
|
'type': 'plot', 'settings': self.get(tab_id)}
|
|
122
|
-
items = copy.deepcopy(self.summary.
|
|
130
|
+
items = copy.deepcopy(self.summary.views or [])
|
|
131
|
+
for i, old in enumerate(items):
|
|
132
|
+
if old.get('id') == preset_id:
|
|
133
|
+
items[i] = item
|
|
134
|
+
break
|
|
135
|
+
else:
|
|
136
|
+
items.append(item)
|
|
137
|
+
self.summary.views = items
|
|
138
|
+
self.revision += 1
|
|
139
|
+
return preset_id
|
|
140
|
+
|
|
141
|
+
def retain_table(self, name, settings, frame=None, *, preset_id=None):
|
|
142
|
+
"""Save table settings only; data are sourced from the owning Summary.
|
|
143
|
+
|
|
144
|
+
``frame`` is accepted for compatibility with callers of the older API,
|
|
145
|
+
but is never serialized. Legacy table snapshots are migrated on save.
|
|
146
|
+
"""
|
|
147
|
+
name = str(name).strip()
|
|
148
|
+
if not name:
|
|
149
|
+
raise ValueError('Configuration name must not be empty')
|
|
150
|
+
preset_id = preset_id or uuid.uuid4().hex
|
|
151
|
+
item = {'schema_version': 1, 'id': preset_id, 'name': name,
|
|
152
|
+
'type': 'table', 'settings': _json_ready(settings)}
|
|
153
|
+
items = self.saved()
|
|
123
154
|
for i, old in enumerate(items):
|
|
124
155
|
if old.get('id') == preset_id:
|
|
125
156
|
items[i] = item
|
|
126
157
|
break
|
|
127
158
|
else:
|
|
128
159
|
items.append(item)
|
|
129
|
-
self.summary.
|
|
160
|
+
self.summary.views = items
|
|
130
161
|
self.revision += 1
|
|
131
162
|
return preset_id
|
|
132
163
|
|
|
164
|
+
def restore_table(self, preset_id):
|
|
165
|
+
item = next((p for p in self.saved() if p.get('id') == preset_id), None)
|
|
166
|
+
if item is None or item.get('type') != 'table':
|
|
167
|
+
raise KeyError(preset_id)
|
|
168
|
+
# Legacy frame snapshots are intentionally ignored. All tables now
|
|
169
|
+
# resolve against the current owning dataset, not a frozen copy.
|
|
170
|
+
return self.summary.copy(deep=False), copy.deepcopy(item['settings'])
|
|
171
|
+
|
|
133
172
|
def delete_saved(self, preset_id):
|
|
134
173
|
"""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')
|
|
174
|
+
items = copy.deepcopy(self.summary.views or [])
|
|
175
|
+
remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') in ('plot', 'table'))]
|
|
137
176
|
if len(remaining) == len(items):
|
|
138
177
|
raise KeyError(f'Unknown saved plot: {preset_id}')
|
|
139
|
-
self.summary.
|
|
178
|
+
self.summary.views = remaining
|
|
140
179
|
self.revision += 1
|
|
141
180
|
|
|
142
181
|
def saved(self):
|
|
143
|
-
|
|
182
|
+
from .view_schema import migrate_saved_views
|
|
183
|
+
return migrate_saved_views(self.summary.views)
|
|
144
184
|
|
|
145
185
|
def restore(self, preset_id, tab_id):
|
|
146
186
|
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
|
+
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Filtering, aggregation and temporal segmentation."""
|
|
2
2
|
from __future__ import annotations
|
|
3
|
+
from .profiling import traced
|
|
3
4
|
import threading
|
|
4
5
|
from datetime import date, datetime, time, timedelta
|
|
5
6
|
from collections.abc import Sequence
|
|
@@ -82,6 +83,7 @@ def _parse_datetime_filter(value):
|
|
|
82
83
|
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
@traced('_apply_filters', kind="data" if 'shiny/data.py' not in ("plot_configs.py", "shiny/matplotlib_renderer.py") else "service")
|
|
85
87
|
def _apply_filters(data, subset_filters, datetime_start=None, datetime_end=None):
|
|
86
88
|
"""Apply Summary filters, accepting dates, times and full datetimes.
|
|
87
89
|
|
|
@@ -123,6 +125,7 @@ def _apply_filters(data, subset_filters, datetime_start=None, datetime_end=None)
|
|
|
123
125
|
|
|
124
126
|
|
|
125
127
|
|
|
128
|
+
@traced('_aggregate', kind="data" if 'shiny/data.py' not in ("plot_configs.py", "shiny/matplotlib_renderer.py") else "service")
|
|
126
129
|
def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
|
|
127
130
|
only_complete=True, flooring_timestamp=True):
|
|
128
131
|
"""Aggregate through ``Summary.group_stat``.
|
|
@@ -138,13 +141,10 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
|
|
|
138
141
|
# Return empty rather than breaking Shiny's reactive graph.
|
|
139
142
|
return pd.DataFrame()
|
|
140
143
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
frequency
|
|
144
|
-
|
|
145
|
-
aggregate=[aggregate],
|
|
146
|
-
**({"only_complete": only_complete, "flooring_timestamp": flooring_timestamp}
|
|
147
|
-
if frequency is not None else {}),
|
|
144
|
+
from .view_aggregation import aggregate_view
|
|
145
|
+
result = aggregate_view(
|
|
146
|
+
data, value_columns, frequency, grouping_by, aggregate,
|
|
147
|
+
only_complete=only_complete, flooring_timestamp=flooring_timestamp,
|
|
148
148
|
)
|
|
149
149
|
if result is None:
|
|
150
150
|
# group_stat() may return None when no rows survive completeness
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Headless, configuration-only table source preparation for Shiny tab actions.
|
|
2
|
+
|
|
3
|
+
The application creates tables from the owning Summary and ordered view
|
|
4
|
+
settings, never from snapshots of already rendered plot/table data.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from .inherited_source import source_frame, source_steps
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def prepare_table_source(summary, *, parent_kind=None, parent_settings=None,
|
|
14
|
+
parent_steps=None, restored_settings=None):
|
|
15
|
+
"""Return (display-ready frame, lineage steps) for new/loaded tables.
|
|
16
|
+
|
|
17
|
+
``parent_kind`` is ``'plot'``, ``'table'`` or ``None`` (Info tab).
|
|
18
|
+
For restoration, pass ``restored_settings`` instead; its saved lineage is
|
|
19
|
+
replayed without modification. No parent DataFrame is copied or cached.
|
|
20
|
+
"""
|
|
21
|
+
if restored_settings is not None:
|
|
22
|
+
steps = restored_settings.get('source_steps', [])
|
|
23
|
+
elif parent_kind in ('plot', 'table'):
|
|
24
|
+
if parent_settings is None:
|
|
25
|
+
raise ValueError('Parent view settings must be supplied')
|
|
26
|
+
steps = source_steps(parent_steps if parent_kind == 'table' else [],
|
|
27
|
+
parent_settings, parent_kind)
|
|
28
|
+
elif parent_kind is None:
|
|
29
|
+
steps = []
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError(f'Unknown parent view kind: {parent_kind!r}')
|
|
32
|
+
frame = source_frame(summary, steps)
|
|
33
|
+
frame = frame.reset_index(drop=True)
|
|
34
|
+
frame.columns = [str(column) for column in frame.columns]
|
|
35
|
+
return frame, steps
|