apmtools 0.2.58__tar.gz → 0.2.59__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 (46) hide show
  1. {apmtools-0.2.58 → apmtools-0.2.59}/PKG-INFO +1 -1
  2. {apmtools-0.2.58 → apmtools-0.2.59}/pyproject.toml +1 -1
  3. apmtools-0.2.59/src/apmtools/shiny/quality_filters.py +76 -0
  4. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/quality_view.py +26 -4
  5. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/tab_initialization.py +20 -0
  6. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/ui.py +157 -20
  7. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/view_schema.py +1 -1
  8. {apmtools-0.2.58 → apmtools-0.2.59}/.gitignore +0 -0
  9. {apmtools-0.2.58 → apmtools-0.2.59}/LICENSE +0 -0
  10. {apmtools-0.2.58 → apmtools-0.2.59}/README.md +0 -0
  11. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/__init__.py +0 -0
  12. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/classes.py +0 -0
  13. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/completeness.py +0 -0
  14. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/data_processing.py +0 -0
  15. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/functions.py +0 -0
  16. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/legacy.py +0 -0
  17. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/plot_app.py +0 -0
  18. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/plot_configs.py +0 -0
  19. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/__init__.py +0 -0
  20. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/appearance.py +0 -0
  21. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/color_editor.py +0 -0
  22. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/columns.py +0 -0
  23. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/config.py +0 -0
  24. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/data.py +0 -0
  25. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/derived_views.py +0 -0
  26. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/elements.py +0 -0
  27. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/export.py +0 -0
  28. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/filter_config.py +0 -0
  29. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/histogram_multi.py +0 -0
  30. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/inherited_source.py +0 -0
  31. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/legend_identity.py +0 -0
  32. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
  33. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/multi_axis.py +0 -0
  34. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/multi_stat_plot.py +0 -0
  35. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/multi_y.py +0 -0
  36. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/profile_report.py +0 -0
  37. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/profiling.py +0 -0
  38. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/state.py +0 -0
  39. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/summary.py +0 -0
  40. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/text_editors.py +0 -0
  41. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/transformations.py +0 -0
  42. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/view_aggregation.py +0 -0
  43. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/view_controller.py +0 -0
  44. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/view_history.py +0 -0
  45. {apmtools-0.2.58 → apmtools-0.2.59}/src/apmtools/shiny/workspace_controller.py +0 -0
  46. {apmtools-0.2.58 → apmtools-0.2.59}/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.59
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.59"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -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
@@ -56,15 +56,37 @@ def selected_heatmap_variable(value_cols, selected):
56
56
  return selected if selected in choices else (choices[0] if choices else None)
57
57
 
58
58
 
59
- def quality_heatmap_data(report, variable):
60
- """Interval coverage matrix (identifiers x bins); missing data remain NaN."""
59
+ def quality_heatmap_data(report, variable, *, mode='coverage'):
60
+ """Return interval-by-identifier heatmap values on a 0–1 scale.
61
+
62
+ ``mode='coverage'`` shows the measured observed/expected fraction, without
63
+ reference to the completeness threshold. ``mode='threshold'`` is the
64
+ historical binary classification (1=complete, 0=incomplete).
65
+ ``mode='threshold_coverage'`` shows 1 for bins meeting the threshold, and
66
+ the *actual* fraction for bins failing it. This retains the continuous
67
+ shades for incomplete bins without losing the threshold classification.
68
+ Undefined expected counts remain NaN in every mode.
69
+ """
70
+ if mode not in ('coverage', 'threshold', 'threshold_coverage'):
71
+ raise ValueError("heatmap mode must be 'coverage', 'threshold', or 'threshold_coverage'")
61
72
  if report.empty:
62
73
  return pd.DataFrame()
63
74
  key = report.columns[0]
64
- portion = report.loc[report['variable'].eq(variable), [key, 'interval', 'completeness']].copy()
75
+ portion = report.loc[report['variable'].eq(variable)].copy()
65
76
  if portion.empty:
66
77
  return pd.DataFrame()
67
- return portion.pivot_table(index=key, columns='interval', values='completeness', aggfunc='mean', dropna=False)
78
+ if mode == 'threshold':
79
+ portion['_heatmap_value'] = portion['is_complete'].astype(float).where(
80
+ portion['expected'].notna())
81
+ elif mode == 'threshold_coverage':
82
+ portion['_heatmap_value'] = portion['completeness'].where(
83
+ ~portion['is_complete'].astype(bool), 1.0)
84
+ portion['_heatmap_value'] = portion['_heatmap_value'].where(
85
+ portion['expected'].notna())
86
+ else:
87
+ portion['_heatmap_value'] = portion['completeness']
88
+ return portion.pivot_table(index=key, columns='interval',
89
+ values='_heatmap_value', aggfunc='mean', dropna=False)
68
90
 
69
91
 
70
92
  def missing_interval_runs(report):
@@ -66,6 +66,26 @@ class TabInitializer:
66
66
  frame, steps = prepare_table_source(self.summary)
67
67
  return TableInitialization(f'Table {number}', frame, None, steps)
68
68
 
69
+ def new_quality(self, selected, number):
70
+ """Create a quality view from the current plot/table's transformed data.
71
+
72
+ The stored lineage replays the source transformation on restoration;
73
+ no potentially stale dataframe snapshots are retained in saved views.
74
+ """
75
+ kind = self.workspace.kind(selected)
76
+ if kind == 'table':
77
+ frame, steps = prepare_table_source(
78
+ self.summary, parent_kind='table',
79
+ parent_steps=self.workspace.table_lineage.get(selected, []),
80
+ parent_settings=self.workspace.table_settings[selected]())
81
+ elif kind == 'plot':
82
+ frame, steps = prepare_table_source(
83
+ self.summary, parent_kind='plot',
84
+ parent_settings=self.workspace.plot_snapshots[selected]())
85
+ else:
86
+ frame, steps = prepare_table_source(self.summary)
87
+ return TableInitialization(f'Quality {number}', frame, {'source_steps': steps}, steps)
88
+
69
89
  def restored_table(self, preset_id, number):
70
90
  preset = self.views.preset(preset_id)
71
91
  _source, settings = self.views.restore_table(preset_id)
@@ -15,6 +15,8 @@ from .config import VARIABLE_UNITS, PLOT_TYPES, FREQUENCIES, AGGREGATES, _MPL_PA
15
15
  from .columns import _alphabetical, _categorical_columns, _datetime_columns, _default_group, _default_x, _normalise_y, _numeric_columns, _timedelta_columns
16
16
  from .data import _aggregate, _apply_filters, _composite_labels, _subset_choices
17
17
  from .view_aggregation import normalize_statistics
18
+ from .quality_filters import filter_quality_data
19
+ from .derived_views import prepare_table_source
18
20
  from .quality_view import quality_report, quality_summary, quality_heatmap_data, missing_interval_runs, QUALITY_FREQUENCIES, selected_heatmap_variable
19
21
  from .matplotlib_renderer import _axis_bounds_from_spec, _format_panel_bounds, _matplotlib_defaults, _matplotlib_direct_figure, _pad_axis_bounds, _panel_axis_bounds, _panel_partitions, _panel_trace_defaults
20
22
  from .elements import element_keys, filter_elements, default_visible_keys
@@ -2033,16 +2035,54 @@ if (!window.__apmColorPickerInstalled) {
2033
2035
 
2034
2036
  # Quality dashboards have independent controls and do not modify plot data.
2035
2037
  @module.ui
2036
- def quality_tab_ui(settings=None):
2038
+ def quality_tab_ui(frame, settings=None):
2037
2039
  settings = settings or {}
2038
- id_choices = _alphabetical([c for c in columns if c not in numeric_columns])
2039
- times = datetime_columns
2040
- selected_values = [v for v in settings.get("value_cols", numeric_columns[:1]) if v in numeric_columns]
2040
+ available = _alphabetical(frame.columns)
2041
+ numeric = _alphabetical(_numeric_columns(frame))
2042
+ id_choices = _alphabetical([c for c in available if c not in numeric])
2043
+ times = _datetime_columns(frame)
2044
+ selected_values = [v for v in settings.get("value_cols", numeric[:1]) if v in numeric]
2041
2045
  selected_heatmap = selected_heatmap_variable(selected_values, settings.get("heatmap_variable"))
2046
+ stages = max(2, len(available) + 1)
2042
2047
  return ui.div(
2043
2048
  ui.layout_sidebar(
2044
2049
  ui.sidebar(
2045
- ui.h4("Coverage analysis"),
2050
+ ui.div(ui.navset_tab(
2051
+ ui.nav_panel("Filters",
2052
+ *[
2053
+ ui.div(
2054
+ ui.input_select(f"subset_column_{i}", f"Subset column {i + 1}",
2055
+ choices={"": "Select a column", **{c: c for c in available}},
2056
+ selected=(settings.get("subset_chain", [])[i].get("column", "") if i < len(settings.get("subset_chain", [])) else "")),
2057
+ ui.output_ui(f"subset_values_ui_{i}"),
2058
+ ui.input_select(f"subset_style_{i}", "Filter style",
2059
+ choices={k: k for k in ("all", "any", "negative all", "negative any")},
2060
+ selected=(settings.get("subset_chain", [])[i].get("style", "all") if i < len(settings.get("subset_chain", [])) else "all")),
2061
+ class_="mb-3",
2062
+ ) if i == 0 else ui.panel_conditional(
2063
+ " && ".join(f"input.subset_column_{j} !== ''" for j in range(i)),
2064
+ ui.div(
2065
+ ui.input_select(f"subset_column_{i}", f"Subset column {i + 1}",
2066
+ choices={"": "Select a column", **{c: c for c in available}},
2067
+ selected=(settings.get("subset_chain", [])[i].get("column", "") if i < len(settings.get("subset_chain", [])) else "")),
2068
+ ui.output_ui(f"subset_values_ui_{i}"),
2069
+ ui.input_select(f"subset_style_{i}", "Filter style",
2070
+ choices={k: k for k in ("all", "any", "negative all", "negative any")},
2071
+ selected=(settings.get("subset_chain", [])[i].get("style", "all") if i < len(settings.get("subset_chain", [])) else "all")),
2072
+ class_="mb-3",
2073
+ ),
2074
+ ) for i in range(stages)
2075
+ ],
2076
+ ui.input_selectize("numeric_subset_columns", "Numerical subset",
2077
+ choices={c: c for c in numeric}, multiple=True,
2078
+ selected=settings.get("numeric_subset_columns", [])),
2079
+ ui.output_ui("numeric_subset_bounds"),
2080
+ ui.input_text("datetime_start", "Start datetime", value=settings.get("datetime_start", ""),
2081
+ placeholder="YYYY-MM-DD, HH:MM:SS, or YYYY-MM-DD HH:MM:SS"),
2082
+ ui.input_text("datetime_end", "End datetime", value=settings.get("datetime_end", ""),
2083
+ placeholder="YYYY-MM-DD, HH:MM:SS, or YYYY-MM-DD HH:MM:SS"),
2084
+ ),
2085
+ ui.nav_panel("Coverage analysis",
2046
2086
  ui.input_select("id_col", "Monitor / identifier column",
2047
2087
  choices=id_choices,
2048
2088
  selected=settings.get("id_col", "identifier") if settings.get("id_col", "identifier") in id_choices else (id_choices[0] if id_choices else None)),
@@ -2050,7 +2090,7 @@ if (!window.__apmColorPickerInstalled) {
2050
2090
  choices=times,
2051
2091
  selected=settings.get("time_col", "datetime") if settings.get("time_col", "datetime") in times else (times[0] if times else None)),
2052
2092
  ui.input_selectize("value_cols", "Measurements",
2053
- choices=numeric_columns, multiple=True,
2093
+ choices=numeric, multiple=True,
2054
2094
  selected=selected_values),
2055
2095
  ui.input_select("frequency", "Aggregation frequency",
2056
2096
  choices={v: v.capitalize() for v in QUALITY_FREQUENCIES},
@@ -2069,6 +2109,7 @@ if (!window.__apmColorPickerInstalled) {
2069
2109
  "Intervals outside the recording span cannot be inferred.",
2070
2110
  class_="small text-muted"),
2071
2111
  ui.download_button("download_quality_csv", "Export interval coverage CSV"),
2112
+ ), id="quality_sidebar_tabs"), class_="summary-sidebar-tabs"),
2072
2113
  title="Data quality", position="left", open="open", width="320px",
2073
2114
  ),
2074
2115
  ui.div(
@@ -2087,8 +2128,93 @@ if (!window.__apmColorPickerInstalled) {
2087
2128
  )
2088
2129
 
2089
2130
  @module.server
2090
- def quality_tab_server(input, output, session, settings=None):
2131
+ def quality_tab_server(input, output, session, frame, settings=None):
2091
2132
  settings = settings or {}
2133
+ available = _alphabetical(frame.columns)
2134
+ numeric = _alphabetical(_numeric_columns(frame))
2135
+ stages = max(2, len(available) + 1)
2136
+ choice_cache = {}
2137
+
2138
+ def choices_for(name):
2139
+ if name not in choice_cache:
2140
+ choice_cache[name] = _subset_choices(frame, name)
2141
+ return choice_cache[name]
2142
+
2143
+ def make_values_ui(stage):
2144
+ def values_ui():
2145
+ try:
2146
+ name = input[f"subset_column_{stage}"]()
2147
+ except Exception:
2148
+ return ui.div()
2149
+ if name not in frame.columns:
2150
+ return ui.div()
2151
+ choices, _ = choices_for(name)
2152
+ selected = (settings.get("subset_chain", [])[stage].get("values", [])
2153
+ if stage < len(settings.get("subset_chain", [])) else [])
2154
+ return ui.input_selectize(f"subset_values_{stage}", f"{name} values",
2155
+ choices=choices, multiple=True, selected=selected)
2156
+ values_ui.__name__ = f"subset_values_ui_{stage}"
2157
+ return render.ui(values_ui)
2158
+
2159
+ for stage in range(stages):
2160
+ output(make_values_ui(stage))
2161
+
2162
+ @render.ui
2163
+ def numeric_subset_bounds():
2164
+ controls = []
2165
+ for name in list(input.numeric_subset_columns() or []):
2166
+ if name not in numeric:
2167
+ continue
2168
+ index = numeric.index(name)
2169
+ controls.append(ui.div(
2170
+ ui.tags.strong(name),
2171
+ ui.input_text(f"numeric_gt_{index}", "Greater than", placeholder="No lower bound",
2172
+ value=settings.get("numeric_subset_bounds", {}).get(name, {}).get("gt", "")),
2173
+ ui.input_text(f"numeric_lt_{index}", "Less than", placeholder="No upper bound",
2174
+ value=settings.get("numeric_subset_bounds", {}).get(name, {}).get("lt", "")),
2175
+ ))
2176
+ return ui.div(*controls)
2177
+
2178
+ @reactive.calc
2179
+ def filter_settings():
2180
+ chain = []
2181
+ for stage in range(stages):
2182
+ try:
2183
+ column = input[f"subset_column_{stage}"]()
2184
+ except Exception:
2185
+ break
2186
+ if column not in frame.columns:
2187
+ break
2188
+ try:
2189
+ values = list(input[f"subset_values_{stage}"]() or [])
2190
+ except Exception:
2191
+ values = (settings.get("subset_chain", [])[stage].get("values", [])
2192
+ if stage < len(settings.get("subset_chain", [])) else [])
2193
+ try:
2194
+ style = input[f"subset_style_{stage}"]() or "all"
2195
+ except Exception:
2196
+ style = "all"
2197
+ chain.append({"column": column, "values": values, "style": style})
2198
+ bounds = {}
2199
+ for name in list(input.numeric_subset_columns() or []):
2200
+ if name not in numeric:
2201
+ continue
2202
+ index = numeric.index(name)
2203
+ bounds[name] = {}
2204
+ for suffix in ("gt", "lt"):
2205
+ try:
2206
+ bounds[name][suffix] = input[f"numeric_{suffix}_{index}"]() or ""
2207
+ except Exception:
2208
+ bounds[name][suffix] = ""
2209
+ return {"subset_chain": chain,
2210
+ "numeric_subset_columns": list(input.numeric_subset_columns() or []),
2211
+ "numeric_subset_bounds": bounds,
2212
+ "datetime_start": input.datetime_start() or "",
2213
+ "datetime_end": input.datetime_end() or ""}
2214
+
2215
+ @reactive.calc
2216
+ def filtered_data():
2217
+ return filter_quality_data(frame, {**filter_settings(), "time_col": input.time_col()})
2092
2218
 
2093
2219
  @reactive.calc
2094
2220
  def report_settings():
@@ -2116,7 +2242,7 @@ if (!window.__apmColorPickerInstalled) {
2116
2242
  @reactive.calc
2117
2243
  def quality_settings():
2118
2244
  conf = report_settings()
2119
- return {**conf, "heatmap_variable": selected_heatmap_variable(
2245
+ return {**conf, **filter_settings(), "source_steps": settings.get("source_steps", []), "heatmap_variable": selected_heatmap_variable(
2120
2246
  conf["value_cols"], input.heatmap_variable())}
2121
2247
 
2122
2248
  @reactive.calc
@@ -2124,7 +2250,10 @@ if (!window.__apmColorPickerInstalled) {
2124
2250
  conf = report_settings()
2125
2251
  if not conf["value_cols"] or not conf["id_col"] or not conf["time_col"]:
2126
2252
  return pd.DataFrame()
2127
- return quality_report(summary, conf)
2253
+ data = filtered_data()
2254
+ if data.empty:
2255
+ return pd.DataFrame()
2256
+ return quality_report(data, conf)
2128
2257
 
2129
2258
  @render.ui
2130
2259
  def quality_status():
@@ -2134,7 +2263,7 @@ if (!window.__apmColorPickerInstalled) {
2134
2263
  return ui.p("Select a measurement to analyse coverage.", class_="text-muted")
2135
2264
  missing_bins = int((result["observed"] == 0).sum())
2136
2265
  return ui.p(f"{len(result):,} measurement intervals; "
2137
- f"{int(result['is_complete'].sum()):,} complete; "
2266
+ f"{int(result['is_complete'].sum()):,} complete at ≥ {report_settings()['min_completeness']:.0%}; "
2138
2267
  f"{missing_bins:,} entirely missing.", class_="text-muted")
2139
2268
  except (ValueError, KeyError) as exc:
2140
2269
  return ui.p(str(exc), class_="text-danger")
@@ -2163,7 +2292,7 @@ if (!window.__apmColorPickerInstalled) {
2163
2292
  import matplotlib.pyplot as plt
2164
2293
  from matplotlib.colors import Normalize
2165
2294
  conf = quality_settings()
2166
- matrix = quality_heatmap_data(report(), conf["heatmap_variable"])
2295
+ matrix = quality_heatmap_data(report(), conf["heatmap_variable"], mode="threshold_coverage")
2167
2296
  fig, ax = plt.subplots(figsize=(10, 4))
2168
2297
  if matrix.empty:
2169
2298
  ax.text(0.5, 0.5, "No data for selected measurement", ha="center", va="center")
@@ -2180,8 +2309,9 @@ if (!window.__apmColorPickerInstalled) {
2180
2309
  ax.set_xticklabels([str(matrix.columns[i]) for i in ticks], rotation=35, ha="right", fontsize=8)
2181
2310
  ax.set_xlabel("Aggregation interval")
2182
2311
  ax.set_ylabel(conf["id_col"])
2183
- ax.set_title(f"{conf['heatmap_variable']} coverage")
2184
- fig.colorbar(image, ax=ax, label="Observed / expected")
2312
+ ax.set_title(f"{conf['heatmap_variable']} — complete at ≥ {conf['min_completeness']:.0%} coverage")
2313
+ fig.colorbar(image, ax=ax, ticks=[0, .25, .5, .75, 1],
2314
+ label="Coverage (fraction; passing bins displayed as 100%)")
2185
2315
  fig.tight_layout()
2186
2316
  return fig
2187
2317
 
@@ -3010,9 +3140,10 @@ if (!window.__apmColorPickerInstalled) {
3010
3140
  number = next_quality.get() + 1
3011
3141
  tab_id = f"quality_{number}"
3012
3142
  settings = views.restore_quality(preset_id)
3143
+ frame, _ = prepare_table_source(summary, restored_settings=settings)
3013
3144
  panel = ui.nav_panel(preset.get('name') or f"Quality {number}",
3014
- quality_tab_ui(tab_id, settings), value=tab_id)
3015
- snapshot = quality_tab_server(tab_id, settings)
3145
+ quality_tab_ui(tab_id, frame, settings), value=tab_id)
3146
+ snapshot = quality_tab_server(tab_id, frame, settings)
3016
3147
  workspace.add_quality(tab_id, snapshot)
3017
3148
  begin_history(tab_id, settings, restored=True)
3018
3149
  register_quality_history(tab_id)
@@ -3059,10 +3190,15 @@ if (!window.__apmColorPickerInstalled) {
3059
3190
  def add_quality():
3060
3191
  number = next_quality.get() + 1
3061
3192
  tab_id = f"quality_{number}"
3062
- panel = ui.nav_panel(f"Quality {number}", quality_tab_ui(tab_id), value=tab_id)
3063
- settings = quality_tab_server(tab_id)
3193
+ selected = input.plot_tabs()
3194
+ with reactive.isolate():
3195
+ initialized = initializer.new_quality(selected, number)
3196
+ frame, steps = initialized.frame, initialized.lineage
3197
+ initial_settings = {"source_steps": steps}
3198
+ panel = ui.nav_panel(f"Quality {number}", quality_tab_ui(tab_id, frame, initial_settings), value=tab_id)
3199
+ settings = quality_tab_server(tab_id, frame, initial_settings)
3064
3200
  workspace.add_quality(tab_id, settings)
3065
- begin_history(tab_id, {})
3201
+ begin_history(tab_id, initial_settings)
3066
3202
  register_quality_history(tab_id)
3067
3203
  next_quality.set(number)
3068
3204
  active_tabs.set(workspace.active_tabs)
@@ -3107,8 +3243,9 @@ if (!window.__apmColorPickerInstalled) {
3107
3243
  number = next_quality.get() + 1
3108
3244
  next_quality.set(number)
3109
3245
  replacement = f"quality_{number}"
3110
- panel = ui.nav_panel(f"Quality {number}", quality_tab_ui(replacement, settings), value=replacement)
3111
- quality_snapshot = quality_tab_server(replacement, settings)
3246
+ frame, _ = prepare_table_source(summary, restored_settings=settings)
3247
+ panel = ui.nav_panel(f"Quality {number}", quality_tab_ui(replacement, frame, settings), value=replacement)
3248
+ quality_snapshot = quality_tab_server(replacement, frame, settings)
3112
3249
  workspace.add_quality(replacement, quality_snapshot)
3113
3250
  register_quality_history(replacement)
3114
3251
  elif kind == "plot":
@@ -98,7 +98,7 @@ def inspect_saved_views(views, columns=None):
98
98
  continue
99
99
  if value not in available:
100
100
  issues.append(ViewIssue(identifier, f'settings.{field}', 'warning', f'Column not found: {value}'))
101
- chain = settings.get('source_steps', []) if view['type'] == 'table' else []
101
+ chain = settings.get('source_steps', []) if view['type'] in ('table', 'quality') else []
102
102
  if not isinstance(chain, list):
103
103
  issues.append(ViewIssue(identifier, 'settings.source_steps', 'error', 'Expected a list of transformation steps'))
104
104
  elif any(not isinstance(step, dict) for step in chain):
File without changes
File without changes
File without changes