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.
Files changed (45) hide show
  1. {apmtools-0.2.55 → apmtools-0.2.57}/PKG-INFO +1 -1
  2. {apmtools-0.2.55 → apmtools-0.2.57}/pyproject.toml +1 -1
  3. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/classes.py +41 -41
  4. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/plot_app.py +75 -13
  5. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/plot_configs.py +47 -7
  6. apmtools-0.2.57/src/apmtools/shiny/appearance.py +149 -0
  7. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/data.py +7 -7
  8. apmtools-0.2.57/src/apmtools/shiny/derived_views.py +35 -0
  9. apmtools-0.2.57/src/apmtools/shiny/inherited_source.py +132 -0
  10. apmtools-0.2.57/src/apmtools/shiny/legend_identity.py +58 -0
  11. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/matplotlib_renderer.py +26 -18
  12. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/multi_axis.py +2 -74
  13. apmtools-0.2.57/src/apmtools/shiny/multi_stat_plot.py +53 -0
  14. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/multi_y.py +13 -6
  15. apmtools-0.2.57/src/apmtools/shiny/profile_report.py +51 -0
  16. apmtools-0.2.57/src/apmtools/shiny/profiling.py +103 -0
  17. apmtools-0.2.57/src/apmtools/shiny/state.py +67 -0
  18. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/summary.py +3 -2
  19. apmtools-0.2.57/src/apmtools/shiny/tab_initialization.py +74 -0
  20. apmtools-0.2.57/src/apmtools/shiny/text_editors.py +23 -0
  21. apmtools-0.2.57/src/apmtools/shiny/transformations.py +208 -0
  22. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/ui.py +719 -552
  23. apmtools-0.2.57/src/apmtools/shiny/view_aggregation.py +124 -0
  24. apmtools-0.2.57/src/apmtools/shiny/view_controller.py +91 -0
  25. apmtools-0.2.57/src/apmtools/shiny/view_history.py +106 -0
  26. apmtools-0.2.57/src/apmtools/shiny/workspace_controller.py +58 -0
  27. apmtools-0.2.57/src/apmtools/view_schema.py +106 -0
  28. apmtools-0.2.55/src/apmtools/shiny/appearance.py +0 -259
  29. apmtools-0.2.55/src/apmtools/shiny/plotly_renderer.py +0 -400
  30. {apmtools-0.2.55 → apmtools-0.2.57}/.gitignore +0 -0
  31. {apmtools-0.2.55 → apmtools-0.2.57}/LICENSE +0 -0
  32. {apmtools-0.2.55 → apmtools-0.2.57}/README.md +0 -0
  33. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/__init__.py +0 -0
  34. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/data_processing.py +0 -0
  35. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/functions.py +0 -0
  36. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/legacy.py +0 -0
  37. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/__init__.py +0 -0
  38. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/color_editor.py +0 -0
  39. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/columns.py +0 -0
  40. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/config.py +0 -0
  41. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/elements.py +0 -0
  42. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/export.py +0 -0
  43. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/filter_config.py +0 -0
  44. {apmtools-0.2.55 → apmtools-0.2.57}/src/apmtools/shiny/histogram_multi.py +0 -0
  45. {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.55
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>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.2.55"
7
+ version = "0.2.57"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -1011,7 +1011,7 @@ class Group(pd.DataFrame):
1011
1011
 
1012
1012
  class Summary(pd.DataFrame):
1013
1013
 
1014
- _metadata = ['metadata', 'plots', 'variable', 'description']
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.plots = None
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 == "plots" and value is not None and not isinstance(value, list):
1030
- raise TypeError("Summary.plot must be None or a list")
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
- for name, default in (("plots", None), ("variable", 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),
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.plots = getattr(other, "plots", None)
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.plots = None
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.plots = None
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
- df = self.dropna(subset=[time_col]).copy()
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
- if isinstance(frequency, str):
1546
- if grouping_by != None:
1547
- output = df.groupby(grouping_by+[frequency], dropna=False)
1548
- else:
1549
- output = df.groupby([frequency], dropna=False)
1550
- if isinstance(frequency, list):
1551
- if grouping_by != None:
1552
- output = df.groupby(grouping_by+frequency, dropna=False)
1553
- else:
1554
- output = df.groupby(frequency, dropna=False)
1555
- if frequency == None:
1556
- if grouping_by != None:
1557
- output = df.groupby(grouping_by, dropna=False)
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.plots = None
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="plotly"):
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.div(id="standalone-save-dataset-slot"),
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.script("""
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.plots must be a list or None')
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.plots or [])
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.plots = items
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.plots or [])
136
- remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') == 'plot')]
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.plots = remaining
178
+ self.summary.views = remaining
140
179
  self.revision += 1
141
180
 
142
181
  def saved(self):
143
- return copy.deepcopy(self.summary.plots or [])
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
- result = data.group_stat(
142
- value_cols=value_columns,
143
- frequency=frequency,
144
- grouping_by=grouping_by,
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