apmtools 0.2.58__tar.gz → 0.2.60__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 (51) hide show
  1. {apmtools-0.2.58 → apmtools-0.2.60}/PKG-INFO +1 -1
  2. {apmtools-0.2.58 → apmtools-0.2.60}/pyproject.toml +1 -1
  3. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/completeness.py +15 -0
  4. apmtools-0.2.60/src/apmtools/shiny/bar_axis_autoscale.py +37 -0
  5. apmtools-0.2.60/src/apmtools/shiny/bar_stacking.py +58 -0
  6. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/color_editor.py +7 -1
  7. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/data.py +15 -0
  8. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/export.py +16 -1
  9. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/matplotlib_renderer.py +114 -10
  10. apmtools-0.2.60/src/apmtools/shiny/panel_activation.py +11 -0
  11. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/profiling.py +29 -0
  12. apmtools-0.2.60/src/apmtools/shiny/quality_filters.py +76 -0
  13. apmtools-0.2.60/src/apmtools/shiny/quality_source.py +52 -0
  14. apmtools-0.2.60/src/apmtools/shiny/quality_view.py +198 -0
  15. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/state.py +5 -2
  16. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/tab_initialization.py +23 -0
  17. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/ui.py +446 -105
  18. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/view_schema.py +1 -1
  19. apmtools-0.2.58/src/apmtools/shiny/quality_view.py +0 -96
  20. {apmtools-0.2.58 → apmtools-0.2.60}/.gitignore +0 -0
  21. {apmtools-0.2.58 → apmtools-0.2.60}/LICENSE +0 -0
  22. {apmtools-0.2.58 → apmtools-0.2.60}/README.md +0 -0
  23. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/__init__.py +0 -0
  24. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/classes.py +0 -0
  25. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/data_processing.py +0 -0
  26. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/functions.py +0 -0
  27. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/legacy.py +0 -0
  28. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/plot_app.py +0 -0
  29. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/plot_configs.py +0 -0
  30. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/__init__.py +0 -0
  31. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/appearance.py +0 -0
  32. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/columns.py +0 -0
  33. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/config.py +0 -0
  34. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/derived_views.py +0 -0
  35. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/elements.py +0 -0
  36. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/filter_config.py +0 -0
  37. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/histogram_multi.py +0 -0
  38. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/inherited_source.py +0 -0
  39. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/legend_identity.py +0 -0
  40. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/multi_axis.py +0 -0
  41. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/multi_stat_plot.py +0 -0
  42. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/multi_y.py +0 -0
  43. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/profile_report.py +0 -0
  44. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/summary.py +0 -0
  45. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/text_editors.py +0 -0
  46. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/transformations.py +0 -0
  47. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/view_aggregation.py +0 -0
  48. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/view_controller.py +0 -0
  49. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/view_history.py +0 -0
  50. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/workspace_controller.py +0 -0
  51. {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.58
3
+ Version: 0.2.60
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.58"
7
+ version = "0.2.60"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -82,10 +82,25 @@ def analyse_completeness(data, value_cols, frequency, *, time_col='datetime',
82
82
  values = list(value_cols)
83
83
  if not values:
84
84
  raise ValueError('value_cols must not be empty')
85
+ # Each column must have one unambiguous role. In particular, selecting
86
+ # the timestamp as the monitor ID makes pandas return a two-column frame
87
+ # for frame[time_col], which pd.to_datetime cannot convert.
85
88
  required = [id_col, time_col, *values]
89
+ if len(set(required)) != len(required):
90
+ raise ValueError(
91
+ 'Identifier, timestamp and measurement columns must be distinct; '
92
+ 'choose different columns in Coverage analysis.'
93
+ )
86
94
  absent = [name for name in required if name not in data.columns]
87
95
  if absent:
88
96
  raise KeyError(f'Missing completeness columns: {absent}')
97
+ # Avoid silently choosing one of several identically named source columns.
98
+ duplicates = [name for name in required if list(data.columns).count(name) > 1]
99
+ if duplicates:
100
+ raise ValueError(
101
+ f'Duplicate source column names {list(dict.fromkeys(duplicates))!r}; '
102
+ 'rename them before calculating completeness.'
103
+ )
89
104
  frame = pd.DataFrame(data)[required].copy()
90
105
  frame[time_col] = pd.to_datetime(frame[time_col], errors='coerce')
91
106
  frame = frame.dropna(subset=[id_col, time_col])
@@ -0,0 +1,37 @@
1
+ """Distinguish auto-populated bar-axis input text from explicit bounds.
2
+
3
+ The Shiny UI historically writes data-derived limits into the same controls
4
+ used for manual overrides. A browser update can lag one render behind a
5
+ change in stacking. Recognising both previous and current generated values
6
+ prevents that stale text being interpreted as an explicit clipping limit.
7
+ """
8
+ from __future__ import annotations
9
+
10
+
11
+ def remember_auto_limits(history, limits):
12
+ """Record the displayed default bounds for comparison across redraws."""
13
+ for field in ('y_min', 'y_max'):
14
+ text = str(limits.get(field, '') or '').strip()
15
+ if text:
16
+ seen = history.setdefault(field, [])
17
+ if text not in seen:
18
+ seen.append(text)
19
+ # Avoid retaining every value ever produced by past datasets.
20
+ if len(seen) > 8:
21
+ del seen[:-8]
22
+
23
+
24
+ def release_automatic_bar_limits(options, history, *, protected=()):
25
+ """Let Matplotlib autoscale limits that the UI filled automatically.
26
+
27
+ Bounds different from the generated values are user edits and remain
28
+ explicit. Restored explicit saved limits are also protected.
29
+ """
30
+ resolved = dict(options)
31
+ for field in ('y_min', 'y_max'):
32
+ if field in protected:
33
+ continue
34
+ text = str(options.get(field, '') or '').strip()
35
+ if text in history.get(field, ()):
36
+ resolved[field] = ''
37
+ return resolved
@@ -0,0 +1,58 @@
1
+ """Stacked-bar grouping, with series identity independent of panel facets."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+
8
+ def selected_stack_columns(groups, stacking, panels=()):
9
+ """Retain requested stack dimensions that are grouped and not faceted."""
10
+ requested = [stacking] if isinstance(stacking, str) else (stacking or ())
11
+ panels = [panels] if isinstance(panels, str) else (panels or ())
12
+ return [name for name in dict.fromkeys(requested)
13
+ if name in groups and name not in panels]
14
+
15
+
16
+ def stacked_bar_legend_key(series_key, stack_key):
17
+ """Legend identity for one combination of adjacent and stacked groups.
18
+
19
+ Keep the existing stack-only name when no adjacent grouping remains. Y
20
+ variables distinguish adjacent bar positions, not grouping combinations.
21
+ """
22
+ adjacent_group = series_key[0]
23
+ return f"{adjacent_group} | {stack_key}" if adjacent_group else stack_key
24
+
25
+
26
+ def stacked_bar_segments(spec, stacking):
27
+ """Yield (x category, adjacent series, stack key, value), in data order.
28
+
29
+ Aggregate duplicate source rows by summation, matching ordinary barplots.
30
+ Adjacent series are combinations of non-stacking grouping columns and Y
31
+ variables; selected stack dimensions identify the segments within a bar.
32
+ """
33
+ frame = spec['data']
34
+ selected = [c for c in selected_stack_columns(spec.get('groups', ()), stacking)
35
+ if c in frame.columns]
36
+ if not selected or spec.get('kind') != 'bar' or frame.empty:
37
+ return []
38
+ remaining = [c for c in spec['groups'] if c not in selected and c in frame.columns]
39
+ cols = [spec['x'], *remaining, *selected]
40
+ ys = [y for y in spec['ys'] if y in frame.columns]
41
+ if not ys:
42
+ return []
43
+ result = []
44
+ for keys, group in frame.groupby(cols[0] if len(cols) == 1 else cols,
45
+ sort=False, dropna=False, observed=True):
46
+ vals = keys if isinstance(keys, tuple) else (keys,)
47
+ category = str(vals[0])
48
+ sides = tuple('None' if pd.isna(v) else str(v) for v in vals[1:1+len(remaining)])
49
+ stacks = tuple('None' if pd.isna(v) else str(v) for v in vals[1+len(remaining):])
50
+ side_label = ' | '.join(sides)
51
+ stack_label = ' | '.join(stacks)
52
+ for y in ys:
53
+ numeric = pd.to_numeric(group[y], errors='coerce').to_numpy(dtype=float, na_value=np.nan)
54
+ valid = numeric[np.isfinite(numeric)]
55
+ if valid.size:
56
+ series = (side_label, str(y) if len(ys) > 1 else '')
57
+ result.append((category, series, stack_label, float(valid.sum())))
58
+ return result
@@ -9,7 +9,7 @@ def valid_hex_color(value):
9
9
  return isinstance(value, str) and _HEX.fullmatch(value.strip()) is not None
10
10
 
11
11
 
12
- def active_color_keys(spec, *, mode="group", panel_by=()):
12
+ def active_color_keys(spec, *, mode="group", panel_by=(), bar_stacking=()):
13
13
  """Return colour dimensions with nonempty plotted observations.
14
14
 
15
15
  Faceting removes its grouping columns. For multi-Y plots, 'both' uses
@@ -21,6 +21,12 @@ def active_color_keys(spec, *, mode="group", panel_by=()):
21
21
  if spec.get("hist_variables"):
22
22
  return tuple(dict.fromkeys(spec["data"].loc[
23
23
  spec["data"][spec["x"]].notna(), "__hist_series"].astype(str)))
24
+ if spec.get("kind") == "bar" and bar_stacking:
25
+ from .bar_stacking import stacked_bar_segments, stacked_bar_legend_key
26
+ panel_vars = [panel_by] if isinstance(panel_by, str) else list(panel_by or ())
27
+ active_spec = {**spec, "groups": [g for g in spec["groups"] if g not in panel_vars]}
28
+ return tuple(dict.fromkeys(stacked_bar_legend_key(segment[1], segment[2])
29
+ for segment in stacked_bar_segments(active_spec, bar_stacking)))
24
30
  from .matplotlib_renderer import _panel_partitions
25
31
  from .multi_axis import records
26
32
  parts = (_panel_partitions(spec, panel_by) if panel_by
@@ -11,6 +11,21 @@ from ..units import upas_units, lascar_units, purple_units
11
11
  from .config import VARIABLE_UNITS, PLOT_TYPES, FREQUENCIES, AGGREGATES, _MPL_PALETTES
12
12
 
13
13
 
14
+ def bar_box_category_choices(categorical_columns, selected_frequency, selected_statistics=None):
15
+ """Add the selected frequency category only with active statistical aggregation.
16
+
17
+ Original categorical columns are always available. A generated temporal
18
+ category is available only when both frequency and statistic are selected.
19
+ """
20
+ frequency = FREQUENCIES.get(selected_frequency, selected_frequency)
21
+ choices = list(categorical_columns)
22
+ from .view_aggregation import normalize_statistics
23
+ if (normalize_statistics(selected_statistics) and frequency is not None
24
+ and frequency in FREQUENCIES.values() and frequency not in choices):
25
+ choices.append(frequency)
26
+ return choices
27
+
28
+
14
29
  def _composite_labels(data: pd.DataFrame, columns: list[str]) -> pd.Series:
15
30
  """Build readable composite labels without dropping or coercing missing groups.
16
31
 
@@ -34,7 +34,11 @@ def _standalone_matplotlib_script(spec, options):
34
34
  import inspect
35
35
  import pprint
36
36
 
37
+ from .bar_stacking import selected_stack_columns, stacked_bar_segments, stacked_bar_legend_key
38
+ from .legend_identity import canonical_legend_key, canonical_legend_map, legend_label
37
39
  dependencies = (
40
+ selected_stack_columns, stacked_bar_segments, stacked_bar_legend_key,
41
+ canonical_legend_key, canonical_legend_map, legend_label,
38
42
  _axis_label, _parse_label_map, _series_style_maps, _bounded_float,
39
43
  _line_style, _scatter_marker, _composite_labels,
40
44
  _frequency_timedelta, _add_gap_sections, _matplotlib_defaults,
@@ -43,6 +47,17 @@ def _standalone_matplotlib_script(spec, options):
43
47
  _fit_inside_legends, _place_outside_legend, _matplotlib_direct_figure,
44
48
  )
45
49
  source = "\n\n".join(inspect.getsource(fn) for fn in dependencies)
50
+ # Stacking helpers are embedded above, not imported from a package in the
51
+ # standalone script. Other legacy renderer imports are unchanged.
52
+ source = source.replace("from .bar_stacking import stacked_bar_segments, stacked_bar_legend_key", "")
53
+ source = source.replace("from .bar_stacking import stacked_bar_segments", "")
54
+ source = source.replace("from .bar_stacking import selected_stack_columns", "")
55
+ source = source.replace("from .bar_stacking import stacked_bar_legend_key", "")
56
+ source = source.replace("from .legend_identity import canonical_legend_map, legend_label", "")
57
+ # Profiling decorators belong to the package and are not available in the
58
+ # standalone script; strip them from embedded source.
59
+ source = "\n".join(line for line in source.split("\n")
60
+ if not line.lstrip().startswith("@traced("))
46
61
  metadata = {key: spec.get(key) for key in
47
62
  ("kind", "x", "ys", "groups", "categories", "frequency")}
48
63
  date_columns = [str(c) for c in spec["data"].columns
@@ -52,7 +67,7 @@ def _standalone_matplotlib_script(spec, options):
52
67
  '# Run: python plot_script.py\n'
53
68
  '# Dependencies: pip install pandas numpy matplotlib\n'
54
69
  'from pathlib import Path\n'
55
- 'import pandas as pd\nimport numpy as np\n'
70
+ 'import pandas as pd\nimport numpy as np\nimport re\n'
56
71
  'from datetime import datetime, date, timedelta\n\n'
57
72
  'VARIABLE_UNITS = ' + pprint.pformat(VARIABLE_UNITS, sort_dicts=True) + '\n'
58
73
  '_MPL_PALETTES = ' + pprint.pformat(_MPL_PALETTES, sort_dicts=True) + '\n\n'
@@ -39,7 +39,7 @@ def _matplotlib_defaults(spec, *, palette="Default"):
39
39
 
40
40
 
41
41
 
42
- def _axis_bounds_from_spec(spec, *, bins=30, density=False, stacked=False, cumulative=0):
42
+ def _axis_bounds_from_spec(spec, *, bins=30, density=False, stacked=False, cumulative=0, bar_stacking=()):
43
43
  """Compute editable axis bounds from the prepared data, without rendering.
44
44
 
45
45
  Datetime X bounds are ISO timestamps; categorical X bounds are intentionally
@@ -97,6 +97,16 @@ def _axis_bounds_from_spec(spec, *, bins=30, density=False, stacked=False, cumul
97
97
  ymax = (histograms.sum(axis=0).max() if stacked and histograms.size
98
98
  else histograms.max() if histograms.size else 0)
99
99
  bounds["y_min"], bounds["y_max"] = "0", format(float(ymax), ".12g")
100
+ elif kind == "bar" and bar_stacking:
101
+ from .bar_stacking import stacked_bar_segments
102
+ segments = stacked_bar_segments(spec, bar_stacking)
103
+ if segments:
104
+ from collections import defaultdict
105
+ totals = defaultdict(float)
106
+ for category, series, stack, value in segments:
107
+ totals[(category, series, value >= 0)] += value
108
+ bounds["y_min"] = format(float(min(0, *totals.values())), ".12g")
109
+ bounds["y_max"] = format(float(max(0, *totals.values())), ".12g")
100
110
  elif kind == "bar":
101
111
  values = pd.to_numeric(df[ys[0]], errors="coerce")
102
112
  cols = [x, "__plot_group"] if spec["groups"] else [x]
@@ -302,10 +312,14 @@ def _place_outside_legend(fig, axes, position="right", font_size=12, ncol=1,
302
312
  old = ax.get_legend()
303
313
  if old is None:
304
314
  continue
315
+ identities = getattr(old, "_apmtools_stack_keys", None)
305
316
  for index, (handle, label) in enumerate(zip(old.legend_handles,
306
317
  [t.get_text() for t in old.get_texts()])):
307
- if label and index not in seen:
308
- seen[index] = (handle, label)
318
+ # Stacked bar series may differ in number and order across facets.
319
+ # Use their unedited group-combination identity rather than position.
320
+ key = ("stack", identities[index]) if identities is not None else ("position", index)
321
+ if label and key not in seen:
322
+ seen[key] = (handle, label)
309
323
  old.set_visible(False)
310
324
  old.set_in_layout(False)
311
325
  if not seen:
@@ -356,9 +370,10 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
356
370
  legend_inside=False, legend_columns=1, multi_y_color_by="group", show_grid=True, font_size=12,
357
371
  line_width=2, marker_size=7, line_alpha="", line_style="",
358
372
  scatter_size="", scatter_marker="", scatter_alpha="", hist_alpha="",
359
- box_showfliers=True, box_orientation="vertical", bar_box_width=1.0,
373
+ box_showfliers=True, box_orientation="vertical", bar_box_width=1.0, bar_stacking=None,
360
374
  title_font_size=14, axis_font_size=12, tick_font_size=10,
361
- legend_font_size=12, panel_font_size=14, plot_dpi=100, bins=30, density=False,
375
+ legend_font_size=12, panel_font_size=14, plot_dpi=100,
376
+ plot_height_pct=100, bins=30, density=False,
362
377
  stacked=False, histtype="bar", cumulative=0,
363
378
  panel_by=None, independent_panel_limits=True, panel_titles="",
364
379
  panel_hspace=0.90, panel_wspace=0.38, _axes=None,
@@ -371,7 +386,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
371
386
  from matplotlib.patches import Patch
372
387
 
373
388
  if _axes is None:
374
- fig, ax = plt.subplots(figsize=(10, 5), dpi=plot_dpi)
389
+ fig, ax = plt.subplots(figsize=(10, 5 * float(plot_height_pct) / 100), dpi=plot_dpi)
375
390
  else:
376
391
  ax = _axes
377
392
  fig = ax.figure
@@ -389,8 +404,22 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
389
404
 
390
405
  # Facet only the native Matplotlib renderer. Each panel receives the
391
406
  # same filtered/aggregated data subset and the same appearance settings.
407
+ if panel_by and _axes is None:
408
+ # A dimension cannot simultaneously define facets and stack segments.
409
+ from .bar_stacking import selected_stack_columns
410
+ blocked = selected_stack_columns(spec.get("groups", ()), bar_stacking) if kind == "bar" else []
411
+ panel_by = [name for name in ([panel_by] if isinstance(panel_by, str) else panel_by)
412
+ if name not in blocked]
392
413
  if panel_by and _axes is None:
393
414
  import math
415
+ from .bar_stacking import stacked_bar_segments
416
+ panel_groups = [panel_by] if isinstance(panel_by, str) else list(panel_by)
417
+ palette_spec = {**spec, "groups": [g for g in spec["groups"] if g not in panel_groups]}
418
+ from .bar_stacking import stacked_bar_legend_key
419
+ full_stack_palette = (list(dict.fromkeys(
420
+ stacked_bar_legend_key(item[1], item[2])
421
+ for item in stacked_bar_segments(palette_spec, bar_stacking)))
422
+ if kind == "bar" and bar_stacking else [])
394
423
  partitions = list(_panel_partitions(spec, panel_by))
395
424
  panel_title_map = _parse_label_map(panel_titles)
396
425
  if partitions:
@@ -402,11 +431,12 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
402
431
  plt.close(fig)
403
432
  ncols = (2 if len(partitions) == 4 else min(3, len(partitions)))
404
433
  nrows = math.ceil(len(partitions) / ncols)
405
- fig, axes = plt.subplots(nrows, ncols, figsize=(5.4*ncols, 4.7*nrows), dpi=plot_dpi,
434
+ fig, axes = plt.subplots(nrows, ncols, figsize=(5.4*ncols, 4.7*nrows * float(plot_height_pct) / 100), dpi=plot_dpi,
406
435
  squeeze=False, sharex=False, sharey=False)
407
436
  for idx, (panel_title, panel_df, other_groups) in enumerate(partitions):
408
437
  panel_spec = {**spec, "data": panel_df, "groups": other_groups,
409
438
  "_original_groups": spec["groups"],
439
+ "_stack_palette": full_stack_palette,
410
440
  "_hide_ungrouped_legend": not bool(other_groups) and not bool(spec.get("hist_variables"))}
411
441
  panel_ax = axes.flat[idx]
412
442
  _matplotlib_direct_figure(panel_spec, title=panel_title_map.get(panel_title, panel_title), x_label=x_label,
@@ -419,10 +449,11 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
419
449
  line_width=line_width, marker_size=marker_size, line_alpha=line_alpha, line_style=line_style,
420
450
  scatter_size=scatter_size, scatter_marker=scatter_marker, scatter_alpha=scatter_alpha,
421
451
  hist_alpha=hist_alpha, box_showfliers=box_showfliers, box_orientation=box_orientation,
422
- bar_box_width=bar_box_width,
452
+ bar_box_width=bar_box_width, bar_stacking=bar_stacking,
423
453
  title_font_size=panel_font_size, axis_font_size=axis_font_size,
424
454
  tick_font_size=tick_font_size, legend_font_size=legend_font_size,
425
455
  panel_font_size=panel_font_size, plot_dpi=plot_dpi,
456
+ plot_height_pct=plot_height_pct,
426
457
  bins=bins, density=density,
427
458
  stacked=stacked, histtype=histtype, cumulative=cumulative, _axes=panel_ax,
428
459
  x_major_ticks=x_major_ticks, y_major_ticks=y_major_ticks,
@@ -433,6 +464,21 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
433
464
  active_axes = [flat_axes[i] for i in range(len(partitions))]
434
465
  for i in range(len(partitions), axes.size):
435
466
  flat_axes[i].set_visible(False)
467
+ if kind == "bar" and bar_stacking and not independent_panel_limits:
468
+ # Matplotlib independently autoscales each facet because their
469
+ # axes are intentionally created with sharey=False. Apply the
470
+ # common stacked range *after* drawing all facets, so the
471
+ # checkbox actually determines whether scales are shared.
472
+ # Individual manual panel bounds still take precedence.
473
+ lows, highs = zip(*(panel_ax.get_ylim() for panel_ax in active_axes))
474
+ common_low, common_high = min(lows), max(highs)
475
+ for (panel_title, _, _), panel_ax in zip(partitions, active_axes):
476
+ lower = _parse_axis_limit(_panel_limit_value(y_min, panel_title))
477
+ upper = _parse_axis_limit(_panel_limit_value(y_max, panel_title))
478
+ panel_ax.set_ylim(
479
+ common_low if lower is None else lower,
480
+ common_high if upper is None else upper,
481
+ )
436
482
  if legend_inside:
437
483
  for panel_ax in active_axes:
438
484
  if panel_ax.get_legend() is not None:
@@ -478,7 +524,45 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
478
524
  else:
479
525
  yield "All observations", df
480
526
 
481
- if spec.get("multi_axis"):
527
+ if kind == "bar" and bar_stacking and any(
528
+ col in df.columns and col in spec["groups"] for col in bar_stacking):
529
+ from .bar_stacking import stacked_bar_segments, stacked_bar_legend_key
530
+ segments = stacked_bar_segments(spec, bar_stacking)
531
+ cats = list(dict.fromkeys(str(v) for v in df[x]))
532
+ series_keys = list(dict.fromkeys(item[1] for item in segments))
533
+ # Colour and legend identities must include non-stacking grouping
534
+ # dimensions too; they determine adjacent bars within each category.
535
+ legend_keys = list(dict.fromkeys(
536
+ stacked_bar_legend_key(item[1], item[2]) for item in segments))
537
+ cat_idx = {cat: i for i, cat in enumerate(cats)}
538
+ series_idx = {item: i for i, item in enumerate(series_keys)}
539
+ bar_width = .82 / max(len(series_keys), 1)
540
+ stacks_positive, stacks_negative = {}, {}
541
+ palette_keys = spec.get("_stack_palette") or legend_keys
542
+ stack_colors = {name: overrides.get(name,
543
+ palette_values[palette_keys.index(name) % len(palette_values)]
544
+ if name in palette_keys else palette_values[0])
545
+ for name in legend_keys}
546
+ for (category, series_key, stack_key, amount) in segments:
547
+ legend_key = stacked_bar_legend_key(series_key, stack_key)
548
+ slot = (category, series_key)
549
+ base_map = stacks_positive if amount >= 0 else stacks_negative
550
+ base = base_map.get(slot, 0.0)
551
+ pos = cat_idx[category] + (series_idx[series_key] - (len(series_keys) - 1) / 2) * bar_width
552
+ ax.bar(pos, amount, bottom=base, width=bar_width * .92 * bar_box_width,
553
+ color=stack_colors[legend_key],
554
+ label=legend_label(rename, legend_key, aliases))
555
+ base_map[slot] = base + amount
556
+ ax.set_xticks(range(len(cats)), cats)
557
+ ax.set_xlim(-.5, max(.5, len(cats) - .5))
558
+ # Preserve a separate handle for every actual group combination,
559
+ # even if its user-edited display label equals another entry's.
560
+ if legend_keys and not spec.get("_hide_ungrouped_legend", False):
561
+ stack_legend = ax.legend(handles=[Patch(facecolor=stack_colors[name],
562
+ label=legend_label(rename, name, aliases))
563
+ for name in legend_keys], title="Legend")
564
+ stack_legend._apmtools_stack_keys = tuple(legend_keys)
565
+ elif spec.get("multi_axis"):
482
566
  from .multi_axis import records, style_maps
483
567
  from matplotlib.lines import Line2D
484
568
  from matplotlib.patches import Patch
@@ -637,6 +721,11 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
637
721
  # avoiding repeated full-column masks for multi-Y plots.
638
722
  grouped_line_rows = ([(n, line_df.loc[line_df[group_col] == n]) for n in labels]
639
723
  if group_col else None)
724
+ # A group can contain many independent traces (e.g. one per identifier
725
+ # or per missing-data section). Their visual identity is the GROUP,
726
+ # not the individual trace. Collect only groups actually drawn so the
727
+ # legend and the editable label keys have one entry per group value.
728
+ plotted_line_groups = set()
640
729
  for y in ys:
641
730
  sets = (grouped_line_rows if grouped_line_rows is not None else
642
731
  [(str(y) if len(ys) > 1 else "All observations", line_df)])
@@ -644,6 +733,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
644
733
  rows = rows.loc[rows[y].notna()]
645
734
  if rows.empty:
646
735
  continue
736
+ plotted_line_groups.add(str(name))
647
737
  segment_col = section_col or id_col
648
738
  parts = (rows.groupby(segment_col, dropna=False, sort=False) if segment_col else [(None, rows)])
649
739
  for _, part in parts:
@@ -655,7 +745,21 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
655
745
  _line_style(styles, name) if name in styles and len(ys) == 1 else
656
746
  multi_dashes[key]),
657
747
  alpha=_bounded_float(alphas, name, 1, 0, 1),
658
- label=("_nolegend_" if multi_y else legend_label(rename, name, aliases)))
748
+ # Repeated identifier/section traces must not
749
+ # create repeated legend entries for one group.
750
+ label="_nolegend_")
751
+ if group_col and not multi_y and not spec.get("_hide_ungrouped_legend", False):
752
+ from matplotlib.lines import Line2D
753
+ visible_groups = [name for name in labels if name in plotted_line_groups]
754
+ if visible_groups:
755
+ handles = [Line2D([], [], color=colors[name],
756
+ linewidth=float(line_width),
757
+ linestyle=_line_style(styles, name),
758
+ alpha=_bounded_float(alphas, name, 1, 0, 1))
759
+ for name in visible_groups]
760
+ ax.legend(handles,
761
+ [legend_label(rename, name, aliases) for name in visible_groups],
762
+ title="Legend")
659
763
  elif kind in ("box", "bar"):
660
764
  # Build categorical groups in a single pass. The former implementation
661
765
  # repeatedly scanned the full DataFrame for every category × group,
@@ -0,0 +1,11 @@
1
+ """Explicit panel selection and stacking eligibility for Shiny plot views."""
2
+
3
+
4
+ def selected_panel_groups(groups, requested, excluded=()):
5
+ """Return eligible, explicitly selected panel dimensions in user order.
6
+
7
+ An empty selection never implies all available grouping columns. The
8
+ enabled/disabled state of the panel controls is handled separately by UI.
9
+ """
10
+ eligible = set(groups or ()) - set(excluded or ())
11
+ return list(dict.fromkeys(name for name in (requested or ()) if name in eligible))
@@ -7,6 +7,7 @@ row values or input contents. Each record is one JSON object.
7
7
  from __future__ import annotations
8
8
 
9
9
  import contextvars
10
+ from collections import deque
10
11
  import functools
11
12
  import json
12
13
  import os
@@ -18,6 +19,28 @@ import uuid
18
19
  _current_span = contextvars.ContextVar('apmtools_trace_span', default=None)
19
20
  _lock = threading.RLock()
20
21
  _session = uuid.uuid4().hex[:12]
22
+ # Diagnostics are populated only when tracing is explicitly enabled.
23
+ _reactive_recent = {}
24
+ _reactive_alert_at = {}
25
+
26
+
27
+ def _reactive_loop_warning(name, now=None, *, threshold=50, window=3.0):
28
+ """Flag unusually repeated effect executions; never diagnose with certainty.
29
+
30
+ Called only on the opt-in tracing path. Keep bounded per-name history, and
31
+ emit at most one warning per window to avoid a runaway diagnostic log.
32
+ """
33
+ now = time.monotonic() if now is None else now
34
+ with _lock:
35
+ recent = _reactive_recent.setdefault(name, deque(maxlen=threshold))
36
+ while recent and now - recent[0] > window:
37
+ recent.popleft()
38
+ recent.append(now)
39
+ if len(recent) < threshold or now - _reactive_alert_at.get(name, -float('inf')) < window:
40
+ return False
41
+ _reactive_alert_at[name] = now
42
+ return True
43
+
21
44
 
22
45
 
23
46
  def enabled():
@@ -71,6 +94,9 @@ def traced(label=None, *, kind='function', trigger=None):
71
94
  _emit({'event': 'execution', 'kind': kind, 'name': name,
72
95
  'trigger': trigger, 'span': span, 'parent': parent,
73
96
  'duration_ms': round(duration / 1e6, 3), 'error': error})
97
+ if kind == 'reactive_effect' and _reactive_loop_warning(name):
98
+ _emit({'event': 'suspected_reactive_loop', 'kind': kind,
99
+ 'name': name, 'executions': 50, 'window_seconds': 3})
74
100
  wrapped.__wrapped_by_apmtools_trace__ = True
75
101
  return wrapped
76
102
  return decorate
@@ -95,6 +121,9 @@ async def _run_async(func, name, kind, trigger, args, kwargs):
95
121
  _emit({'event': 'execution', 'kind': kind, 'name': name,
96
122
  'trigger': trigger, 'span': span, 'parent': parent,
97
123
  'duration_ms': round(duration / 1e6, 3), 'error': error})
124
+ if kind == 'reactive_effect' and _reactive_loop_warning(name):
125
+ _emit({'event': 'suspected_reactive_loop', 'kind': kind,
126
+ 'name': name, 'executions': 50, 'window_seconds': 3})
98
127
 
99
128
 
100
129
  def marker(name, **metadata):
@@ -0,0 +1,76 @@
1
+ """Reusable, Shiny-independent subset filtering for quality dashboards."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from datetime import datetime, time, timedelta
8
+ from .data import _parse_datetime_filter, _subset_choices
9
+
10
+
11
+ def filter_quality_data(frame, settings):
12
+ """Apply the same staged categorical, numeric and date filters as tables.
13
+
14
+ The configured selected values are Shiny selector *keys*, resolved against
15
+ the source dataframe. Missing numeric values remain unchanged, consistent
16
+ with the existing table filtering policy.
17
+ """
18
+ current = frame.copy()
19
+ for step in settings.get('subset_chain', []):
20
+ column = step.get('column')
21
+ if column not in current.columns:
22
+ continue
23
+ _, value_map = _subset_choices(frame, column)
24
+ values = [value_map[key] for key in step.get('values', []) if key in value_map]
25
+ if not values:
26
+ continue
27
+ style = step.get('style', 'all')
28
+ if hasattr(current, 'subset'):
29
+ current = current.subset({column: values}, filter_style=style)
30
+ else:
31
+ matches = current[column].isin([v for v in values if v is not None])
32
+ if any(v is None for v in values):
33
+ matches |= current[column].isna()
34
+ if style in ('negative all', 'negative any'):
35
+ matches = ~matches
36
+ current = current.loc[matches].copy()
37
+ # Derived plot/table frames are often ordinary pandas DataFrames (without
38
+ # Summary.dtfilter). Filter their actual datetime column without assuming
39
+ # that the source column is literally named "datetime".
40
+ time_col = settings.get('time_col')
41
+ if time_col not in current.columns or not pd.api.types.is_datetime64_any_dtype(current[time_col]):
42
+ time_col = next((col for col in current.columns
43
+ if pd.api.types.is_datetime64_any_dtype(current[col])), None)
44
+ if time_col is not None:
45
+ timestamps = pd.to_datetime(current[time_col], errors='coerce')
46
+ mask = pd.Series(True, index=current.index)
47
+ for field, lower in (('datetime_start', True), ('datetime_end', False)):
48
+ kind, value = _parse_datetime_filter(settings.get(field))
49
+ if kind == 'date':
50
+ bound = datetime.combine(value, time.min)
51
+ if lower:
52
+ mask &= timestamps.ge(bound)
53
+ else:
54
+ mask &= timestamps.lt(bound + timedelta(days=1))
55
+ elif kind == 'datetime':
56
+ mask &= timestamps.ge(value) if lower else timestamps.lt(value)
57
+ elif kind == 'time':
58
+ times = timestamps.dt.time
59
+ mask &= times.ge(value) if lower else times.lt(value)
60
+ current = current.loc[mask.fillna(False)].copy()
61
+ for column in settings.get('numeric_subset_columns', []):
62
+ if column not in current.columns or not pd.api.types.is_numeric_dtype(current[column]):
63
+ continue
64
+ values = pd.to_numeric(current[column], errors='coerce')
65
+ for comparison in ('gt', 'lt'):
66
+ raw = settings.get('numeric_subset_bounds', {}).get(column, {}).get(comparison, '')
67
+ try:
68
+ bound = float(str(raw).strip())
69
+ except (ValueError, TypeError):
70
+ continue
71
+ if not np.isfinite(bound):
72
+ continue
73
+ mask = values.gt(bound) if comparison == 'gt' else values.lt(bound)
74
+ current = current.loc[mask.fillna(False) | values.isna()].copy()
75
+ values = values.loc[current.index]
76
+ return current
@@ -0,0 +1,52 @@
1
+ """Construct quality sources from raw observations and inherited filters only.
2
+
3
+ Unlike a derived table, a quality view must never inherit aggregation,
4
+ column projection or plot-specific row selection: it needs the original
5
+ identifier, timestamp and measurement columns to assess data coverage.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+
11
+ from .inherited_source import source_frame
12
+
13
+
14
+ _FILTER_FIELDS = ('subset_chain', 'datetime_start', 'datetime_end',
15
+ 'numeric_subset_columns', 'numeric_subset_bounds')
16
+
17
+
18
+ def quality_filter_settings(settings, columns):
19
+ """Copy only filters valid against raw columns, excluding derived columns."""
20
+ settings = settings or {}
21
+ available = set(columns)
22
+ result = {key: copy.deepcopy(settings[key]) for key in _FILTER_FIELDS if key in settings}
23
+ result['subset_chain'] = [item for item in result.get('subset_chain', [])
24
+ if item.get('column') in available]
25
+ result['numeric_subset_columns'] = [name for name in result.get('numeric_subset_columns', [])
26
+ if name in available]
27
+ result['numeric_subset_bounds'] = {
28
+ name: bounds for name, bounds in result.get('numeric_subset_bounds', {}).items()
29
+ if name in result['numeric_subset_columns']
30
+ }
31
+ return result
32
+
33
+
34
+ def quality_filter_lineage(steps, columns):
35
+ """Convert saved plot/table lineage into filter-only steps on raw data."""
36
+ return [quality_filter_settings(step, columns) for step in (steps or [])]
37
+
38
+
39
+ def prepare_quality_source(summary, *, parent_steps=None, restored_settings=None):
40
+ """Return raw-column dataframe with only inherited filters replayed.
41
+
42
+ Quality's own editable filters live in its tab settings, and are applied
43
+ separately. The source lineage contains only earlier table steps.
44
+ Legacy saved quality views are accepted; all aggregation/projection stages
45
+ are removed when loading them.
46
+ """
47
+ steps = quality_filter_lineage(
48
+ (restored_settings or {}).get('source_steps', []) if restored_settings is not None
49
+ else parent_steps, summary.columns)
50
+ frame = source_frame(summary, steps).reset_index(drop=True)
51
+ frame.columns = [str(column) for column in frame.columns]
52
+ return frame, steps