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.
- {apmtools-0.2.58 → apmtools-0.2.60}/PKG-INFO +1 -1
- {apmtools-0.2.58 → apmtools-0.2.60}/pyproject.toml +1 -1
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/completeness.py +15 -0
- apmtools-0.2.60/src/apmtools/shiny/bar_axis_autoscale.py +37 -0
- apmtools-0.2.60/src/apmtools/shiny/bar_stacking.py +58 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/color_editor.py +7 -1
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/data.py +15 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/export.py +16 -1
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/matplotlib_renderer.py +114 -10
- apmtools-0.2.60/src/apmtools/shiny/panel_activation.py +11 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/profiling.py +29 -0
- apmtools-0.2.60/src/apmtools/shiny/quality_filters.py +76 -0
- apmtools-0.2.60/src/apmtools/shiny/quality_source.py +52 -0
- apmtools-0.2.60/src/apmtools/shiny/quality_view.py +198 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/state.py +5 -2
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/tab_initialization.py +23 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/ui.py +446 -105
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/view_schema.py +1 -1
- apmtools-0.2.58/src/apmtools/shiny/quality_view.py +0 -96
- {apmtools-0.2.58 → apmtools-0.2.60}/.gitignore +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/LICENSE +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/README.md +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/classes.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/plot_app.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/plot_configs.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/appearance.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/derived_views.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/inherited_source.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/legend_identity.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/multi_axis.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/multi_stat_plot.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/multi_y.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/profile_report.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/summary.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/text_editors.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/transformations.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/view_aggregation.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/view_controller.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/view_history.py +0 -0
- {apmtools-0.2.58 → apmtools-0.2.60}/src/apmtools/shiny/workspace_controller.py +0 -0
- {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.
|
|
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>
|
|
@@ -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
|
-
|
|
308
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|