apmtools 0.2.55__tar.gz → 0.2.56__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {apmtools-0.2.55 → apmtools-0.2.56}/PKG-INFO +1 -1
  2. {apmtools-0.2.55 → apmtools-0.2.56}/pyproject.toml +1 -1
  3. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/classes.py +22 -9
  4. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/plot_app.py +75 -13
  5. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/plot_configs.py +66 -7
  6. apmtools-0.2.56/src/apmtools/shiny/appearance.py +149 -0
  7. apmtools-0.2.56/src/apmtools/shiny/inherited_source.py +112 -0
  8. apmtools-0.2.56/src/apmtools/shiny/legend_identity.py +58 -0
  9. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/matplotlib_renderer.py +13 -10
  10. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/multi_axis.py +2 -74
  11. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/multi_y.py +13 -6
  12. apmtools-0.2.56/src/apmtools/shiny/state.py +53 -0
  13. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/summary.py +3 -2
  14. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/ui.py +307 -395
  15. apmtools-0.2.55/src/apmtools/shiny/appearance.py +0 -259
  16. apmtools-0.2.55/src/apmtools/shiny/plotly_renderer.py +0 -400
  17. {apmtools-0.2.55 → apmtools-0.2.56}/.gitignore +0 -0
  18. {apmtools-0.2.55 → apmtools-0.2.56}/LICENSE +0 -0
  19. {apmtools-0.2.55 → apmtools-0.2.56}/README.md +0 -0
  20. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/__init__.py +0 -0
  21. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/data_processing.py +0 -0
  22. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/functions.py +0 -0
  23. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/legacy.py +0 -0
  24. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/__init__.py +0 -0
  25. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/color_editor.py +0 -0
  26. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/columns.py +0 -0
  27. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/config.py +0 -0
  28. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/data.py +0 -0
  29. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/elements.py +0 -0
  30. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/export.py +0 -0
  31. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/filter_config.py +0 -0
  32. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/shiny/histogram_multi.py +0 -0
  33. {apmtools-0.2.55 → apmtools-0.2.56}/src/apmtools/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.55
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>
@@ -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.56"
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):
@@ -1598,7 +1611,7 @@ class Summary(pd.DataFrame):
1598
1611
 
1599
1612
  out = self.subset({"monitor": [value]})
1600
1613
  out = out.dropna(axis=1,how="all")
1601
- out.plots = None
1614
+ out.views = None
1602
1615
  return out
1603
1616
 
1604
1617
 
@@ -8,6 +8,8 @@ from __future__ import annotations
8
8
  from pathlib import Path
9
9
  import pickle
10
10
  import threading
11
+ import os
12
+ import tempfile
11
13
 
12
14
 
13
15
  def load_summary_pickle(path):
@@ -35,6 +37,47 @@ def load_summary_pickle(path):
35
37
  return data
36
38
 
37
39
 
40
+ def save_summary_pickle(summary, destination, *, overwrite=False):
41
+ """Atomically persist the current source Summary and retained plot presets."""
42
+ from .classes import Summary
43
+ if not isinstance(summary, Summary):
44
+ raise TypeError("No Summary dataset is loaded")
45
+ target = Path(destination).expanduser().resolve()
46
+ if target.suffix.lower() != ".pkl":
47
+ raise ValueError("The filename must end in .pkl")
48
+ if not target.parent.is_dir():
49
+ raise FileNotFoundError(f"Directory does not exist: {target.parent}")
50
+ if target.exists() and not overwrite:
51
+ raise FileExistsError(f"File already exists: {target}")
52
+ if target.exists() and not target.is_file():
53
+ raise ValueError(f"Destination is not a regular file: {target}")
54
+ from .plot_configs import validate_saved_plots
55
+ retained = validate_saved_plots(getattr(summary, "plots", None))
56
+ temporary = None
57
+ try:
58
+ with tempfile.NamedTemporaryFile(mode="wb", dir=target.parent,
59
+ prefix=".apmtools-", suffix=".tmp",
60
+ delete=False) as stream:
61
+ temporary = Path(stream.name)
62
+ snapshot = summary.copy(deep=False)
63
+ snapshot.plots = retained
64
+ pickle.dump(snapshot, stream, protocol=pickle.HIGHEST_PROTOCOL)
65
+ stream.flush()
66
+ os.fsync(stream.fileno())
67
+ with temporary.open("rb") as stream:
68
+ restored = pickle.load(stream)
69
+ if getattr(restored, "plots", None) != retained:
70
+ raise RuntimeError("Saved plot configurations failed pickle verification")
71
+ if overwrite:
72
+ os.replace(temporary, target)
73
+ else:
74
+ os.link(temporary, target)
75
+ finally:
76
+ if temporary is not None:
77
+ temporary.unlink(missing_ok=True)
78
+ return target
79
+
80
+
38
81
  class PlotApp:
39
82
  """Launch a Shiny plot workspace with an optional initial Summary.
40
83
 
@@ -45,7 +88,7 @@ class PlotApp:
45
88
  """
46
89
 
47
90
  def __init__(self, summary=None, *, x=None, y=None, group_by=None,
48
- plot_type="line", title="Summary plot", backend="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():
@@ -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.plots must be a list or None')
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.plots or [])
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.plots = items
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.plots or [])
136
- remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') == 'plot')]
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.plots = remaining
194
+ self.summary.views = remaining
140
195
  self.revision += 1
141
196
 
142
197
  def saved(self):
143
- return copy.deepcopy(self.summary.plots or [])
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)