apmtools 0.2.57__tar.gz → 0.2.58__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {apmtools-0.2.57 → apmtools-0.2.58}/PKG-INFO +1 -1
  2. {apmtools-0.2.57 → apmtools-0.2.58}/pyproject.toml +1 -1
  3. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/classes.py +19 -168
  4. apmtools-0.2.58/src/apmtools/completeness.py +148 -0
  5. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/plot_configs.py +26 -2
  6. apmtools-0.2.58/src/apmtools/shiny/quality_view.py +96 -0
  7. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/ui.py +220 -4
  8. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/view_controller.py +14 -3
  9. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/workspace_controller.py +12 -0
  10. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/view_schema.py +1 -1
  11. {apmtools-0.2.57 → apmtools-0.2.58}/.gitignore +0 -0
  12. {apmtools-0.2.57 → apmtools-0.2.58}/LICENSE +0 -0
  13. {apmtools-0.2.57 → apmtools-0.2.58}/README.md +0 -0
  14. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/__init__.py +0 -0
  15. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/data_processing.py +0 -0
  16. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/functions.py +0 -0
  17. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/legacy.py +0 -0
  18. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/plot_app.py +0 -0
  19. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/__init__.py +0 -0
  20. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/appearance.py +0 -0
  21. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/color_editor.py +0 -0
  22. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/columns.py +0 -0
  23. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/config.py +0 -0
  24. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/data.py +0 -0
  25. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/derived_views.py +0 -0
  26. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/elements.py +0 -0
  27. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/export.py +0 -0
  28. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/filter_config.py +0 -0
  29. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/histogram_multi.py +0 -0
  30. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/inherited_source.py +0 -0
  31. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/legend_identity.py +0 -0
  32. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
  33. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/multi_axis.py +0 -0
  34. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/multi_stat_plot.py +0 -0
  35. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/multi_y.py +0 -0
  36. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/profile_report.py +0 -0
  37. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/profiling.py +0 -0
  38. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/state.py +0 -0
  39. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/summary.py +0 -0
  40. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/tab_initialization.py +0 -0
  41. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/text_editors.py +0 -0
  42. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/transformations.py +0 -0
  43. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/view_aggregation.py +0 -0
  44. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/view_history.py +0 -0
  45. {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.57
3
+ Version: 0.2.58
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.57"
7
+ version = "0.2.58"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -9,6 +9,7 @@ from csv import writer
9
9
  from dateutil.parser import parse
10
10
 
11
11
  from .units import upas_units, lascar_units, purple_units, lcm_units
12
+ from .completeness import analyse_completeness, complete_interval_mask
12
13
 
13
14
  class DictionaryPlus(dict):
14
15
  def __init__(self, *args, **kwargs):
@@ -1194,6 +1195,19 @@ class Summary(pd.DataFrame):
1194
1195
  if attribute in self.columns:
1195
1196
  return set(self[attribute])
1196
1197
 
1198
+ def completeness_report(self, value_cols, frequency, *, time_col='datetime',
1199
+ id_col='identifier', min_completeness=1.0,
1200
+ include_empty_intervals=False):
1201
+ """Report observed/expected samples by interval and measurement.
1202
+
1203
+ The result is an ordinary DataFrame with one row per identifier,
1204
+ interval and variable. See :func:`apmtools.completeness.analyse_completeness`.
1205
+ """
1206
+ return analyse_completeness(
1207
+ self, value_cols, frequency, time_col=time_col, id_col=id_col,
1208
+ min_completeness=min_completeness,
1209
+ include_empty_intervals=include_empty_intervals)
1210
+
1197
1211
  def group_stat(self, value_cols, frequency=None, grouping_by=["identifier"], time_col='datetime', id_col='identifier',
1198
1212
  min_completeness=1.0, only_complete=True, flooring_timestamp=True, aggregate=["mean"]):
1199
1213
  """
@@ -1234,174 +1248,11 @@ class Summary(pd.DataFrame):
1234
1248
  if frequency != None:
1235
1249
 
1236
1250
  if only_complete:
1237
- def median_interval(g):
1238
- return g.sort_values().diff().dropna().median()
1239
-
1240
- def sorting_frequency(x):
1241
- if len(list(filter(lambda z: True if "week" in z else False, x))) > 0:
1242
- return list(filter(lambda z: True if "week" in z else False, x))[0]
1243
- if len(list(filter(lambda z: True if "weekday" in z else False, x))) > 0:
1244
- return list(filter(lambda z: True if "weekday" in z else False, x))[0]
1245
- if len(list(filter(lambda z: True if "day" in z else False, x))) > 0:
1246
- return list(filter(lambda z: True if "day" in z else False, x))[0]
1247
- if len(list(filter(lambda z: True if "hour" in z else False, x))) > 0:
1248
- return list(filter(lambda z: True if "hour" in z else False, x))[0]
1249
- if len(list(filter(lambda z: True if "minute" in z else False, x))) > 0:
1250
- return list(filter(lambda z: True if "minute" in z else False, x))[0]
1251
- if len(list(filter(lambda z: True if "second" in z else False, x))) > 0:
1252
- return list(filter(lambda z: True if "second" in z else False, x))[0]
1253
-
1254
- def freq_ranked():
1255
- if isinstance(frequency, str):
1256
- return frequency
1257
- if isinstance(frequency, list):
1258
- return sorting_frequency(frequency)
1259
-
1260
- intervals = df.groupby(id_col)[time_col].apply(median_interval)
1261
- if isinstance(frequency, str):
1262
- if len(frequency.split(" ")) == 1:
1263
- match frequency:
1264
- case "hour":
1265
- expected = (pd.Timedelta(1, "hour") /
1266
- intervals).round().astype(int)
1267
- df["xxxyyyxxx"] = df["datetime"].dt.floor("h")
1268
- case "day":
1269
- expected = (pd.Timedelta(1, "day") /
1270
- intervals).round().astype(int)
1271
- df["xxxyyyxxx"] = df["datetime"].dt.floor("d")
1272
- case "minute":
1273
- expected = (pd.Timedelta(1, "minute") /
1274
- intervals).round().astype(int)
1275
- df["xxxyyyxxx"] = df["datetime"].dt.floor("min")
1276
- case "second":
1277
- expected = (pd.Timedelta(1, "second") /
1278
- intervals).round().astype(int)
1279
- df["xxxyyyxxx"] = df["datetime"].dt.floor("s")
1280
- case "week":
1281
- expected = (pd.Timedelta(1, "W") /
1282
- intervals).round().astype(int)
1283
- df["xxxyyyxxx"] = df["datetime"].dt.floor("w")
1284
- case "weekday":
1285
- expected = (pd.Timedelta(1, "day") /
1286
- intervals).round().astype(int)
1287
- df["xxxyyyxxx"] = df["datetime"].map(
1288
- lambda x: (x.weekday()))
1289
- if len(frequency.split(" ")) == 2:
1290
- mult = float(frequency.split(" ")[0])
1291
- match frequency.split(" ")[1]:
1292
- case "hour":
1293
- expected = (pd.Timedelta(mult, "hour") /
1294
- intervals).round().astype(int)
1295
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1296
- f"{mult}h")
1297
- case "day":
1298
- expected = (pd.Timedelta(mult, "day") /
1299
- intervals).round().astype(int)
1300
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1301
- f"{mult}d")
1302
- case "minute":
1303
- expected = (pd.Timedelta(mult, "minute") /
1304
- intervals).round().astype(int)
1305
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1306
- f"{mult}min")
1307
- case "second":
1308
- expected = (pd.Timedelta(mult, "second") /
1309
- intervals).round().astype(int)
1310
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1311
- f"{mult}s")
1312
- case "week":
1313
- expected = (pd.Timedelta(mult, "W") /
1314
- intervals).round().astype(int)
1315
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1316
- f"{mult}w")
1317
- case "weekday":
1318
- expected = (pd.Timedelta(mult, "day") /
1319
- intervals).round().astype(int)
1320
- df["xxxyyyxxx"] = df["datetime"].map(
1321
- lambda x: (x.weekday//mult))*mult
1322
-
1323
- counts = df.groupby([id_col, "xxxyyyxxx"])[
1324
- value_cols].count()
1325
- expe = counts.index.get_level_values(id_col).map(expected)
1326
- completeness = counts.div(expe, axis=0)
1327
- complete_hours = completeness[completeness >= min_completeness].dropna(
1328
- ).index
1329
- mask = pd.MultiIndex.from_arrays(
1330
- [df[id_col], df["xxxyyyxxx"]]).isin(complete_hours)
1331
- df = df.loc[mask]
1332
-
1333
- if isinstance(frequency, list):
1334
- freq = freq_ranked()
1335
- if len(freq.split(" ")) == 1:
1336
- match freq:
1337
- case "hour":
1338
- expected = (pd.Timedelta(1, "hour") /
1339
- intervals).round().astype(int)
1340
- df["xxxyyyxxx"] = df["datetime"].dt.floor("h")
1341
- case "day":
1342
- expected = (pd.Timedelta(1, "day") /
1343
- intervals).round().astype(int)
1344
- df["xxxyyyxxx"] = df["datetime"].dt.floor("d")
1345
- case "minute":
1346
- expected = (pd.Timedelta(1, "minute") /
1347
- intervals).round().astype(int)
1348
- df["xxxyyyxxx"] = df["datetime"].dt.floor("min")
1349
- case "second":
1350
- expected = (pd.Timedelta(1, "second") /
1351
- intervals).round().astype(int)
1352
- df["xxxyyyxxx"] = df["datetime"].dt.floor("s")
1353
- case "week":
1354
- expected = (pd.Timedelta(1, "W") /
1355
- intervals).round().astype(int)
1356
- df["xxxyyyxxx"] = df["datetime"].dt.floor("w")
1357
- case "weekday":
1358
- expected = (pd.Timedelta(1, "day") /
1359
- intervals).round().astype(int)
1360
- df["xxxyyyxxx"] = df["datetime"].map(
1361
- lambda x: (x.weekday()))
1362
- if len(freq.split(" ")) == 2:
1363
- mult = float(freq.split(" ")[0])
1364
- match freq.split(" ")[1]:
1365
- case "hour":
1366
- expected = (pd.Timedelta(mult, "hour") /
1367
- intervals).round().astype(int)
1368
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1369
- f"{mult}h")
1370
- case "day":
1371
- expected = (pd.Timedelta(mult, "day") /
1372
- intervals).round().astype(int)
1373
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1374
- f"{mult}d")
1375
- case "minute":
1376
- expected = (pd.Timedelta(mult, "minute") /
1377
- intervals).round().astype(int)
1378
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1379
- f"{mult}m")
1380
- case "second":
1381
- expected = (pd.Timedelta(mult, "second") /
1382
- intervals).round().astype(int)
1383
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1384
- f"{mult}s")
1385
- case "week":
1386
- expected = (pd.Timedelta(mult, "W") /
1387
- intervals).round().astype(int)
1388
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1389
- f"{mult}w")
1390
- case "weekday":
1391
- expected = (pd.Timedelta(mult, "day") /
1392
- intervals).round().astype(int)
1393
- df["xxxyyyxxx"] = df["datetime"].map(
1394
- lambda x: (x.weekday//mult))*mult
1395
-
1396
- counts = df.groupby([id_col, "xxxyyyxxx"])[
1397
- value_cols].count()
1398
- expe = counts.index.get_level_values(id_col).map(expected)
1399
- completeness = counts.div(expe, axis=0)
1400
- complete_hours = completeness[completeness >= min_completeness].dropna(
1401
- ).index
1402
- mask = pd.MultiIndex.from_arrays(
1403
- [df[id_col], df["xxxyyyxxx"]]).isin(complete_hours)
1404
- df = df.loc[mask]
1251
+ # Stage 4A: one shared completeness implementation for analysis
1252
+ # and aggregation. Retain existing all-value filtering semantics.
1253
+ df = df.loc[complete_interval_mask(
1254
+ df, value_cols, frequency, time_col=time_col,
1255
+ id_col=id_col, min_completeness=min_completeness)].copy()
1405
1256
 
1406
1257
  if isinstance(frequency, str):
1407
1258
  if len(frequency.split(" ")) == 1:
@@ -0,0 +1,148 @@
1
+ """Sampling completeness calculations shared by Summary and future quality views.
2
+
3
+ Coverage is estimated from each identifier's median positive sampling gap.
4
+ The first and last partially observed bins cannot be classified as complete
5
+ without a known deployment schedule: results describe coverage inside bins
6
+ within the observed recording span, not compliance with a planned deployment.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import pandas as pd
12
+
13
+ _UNITS = {'second': 's', 'minute': 'min', 'hour': 'h', 'day': 'D', 'week': 'W'}
14
+ _DURATION = {'second': pd.Timedelta(seconds=1),
15
+ 'minute': pd.Timedelta(minutes=1),
16
+ 'hour': pd.Timedelta(hours=1),
17
+ 'day': pd.Timedelta(days=1),
18
+ 'week': pd.Timedelta(weeks=1),
19
+ 'weekday': pd.Timedelta(days=1)}
20
+
21
+
22
+ def _primary_frequency(frequency):
23
+ if isinstance(frequency, (tuple, list)):
24
+ if not frequency:
25
+ raise ValueError('frequency cannot be empty')
26
+ # Match the historical group_stat ranking of coarsest first.
27
+ ranking = {'week': 0, 'weekday': 1, 'day': 2, 'hour': 3,
28
+ 'minute': 4, 'second': 5}
29
+ return min(frequency, key=lambda f: ranking[_parse_frequency(f)[1]])
30
+ return frequency
31
+
32
+
33
+ def _parse_frequency(frequency):
34
+ if not isinstance(frequency, str):
35
+ raise ValueError('frequency must be a string or a sequence of strings')
36
+ match = re.fullmatch(r'(?:(\d+(?:\.\d+)?)\s+)?(second|minute|hour|day|week|weekday)', frequency.strip())
37
+ if not match:
38
+ raise ValueError(f'Unsupported completeness frequency: {frequency!r}')
39
+ count = float(match.group(1) or 1)
40
+ if not count.is_integer() or count <= 0:
41
+ raise ValueError('Completeness frequency multiplier must be a positive integer')
42
+ return int(count), match.group(2)
43
+
44
+
45
+ def _interval_bins(timestamps, frequency):
46
+ multiplier, unit = _parse_frequency(frequency)
47
+ series = pd.to_datetime(timestamps)
48
+ if unit == 'weekday':
49
+ # Historical weekday grouping pools weekdays across calendar weeks.
50
+ return (series.dt.weekday // multiplier) * multiplier
51
+ if unit == 'week':
52
+ # Monday-anchored calendar weeks, including multiweek bins.
53
+ starts = series.dt.to_period('W-SUN').dt.start_time
54
+ if multiplier == 1:
55
+ return starts
56
+ epoch = pd.Timestamp('1970-01-05')
57
+ return epoch + ((starts - epoch) // pd.Timedelta(weeks=multiplier)) * pd.Timedelta(weeks=multiplier)
58
+ return series.dt.floor(f'{multiplier}{_UNITS[unit]}')
59
+
60
+
61
+ def analyse_completeness(data, value_cols, frequency, *, time_col='datetime',
62
+ id_col='identifier', min_completeness=1.0,
63
+ include_empty_intervals=False):
64
+ """Return one row per (identifier, frequency bin, measurement).
65
+
66
+ Columns: identifier (or *id_col*), ``interval``, ``variable``,
67
+ ``observed``, ``expected``, ``missing``, ``completeness``, ``is_complete``,
68
+ ``sampling_interval``. ``observed`` counts non-null measurements, not
69
+ distinct timestamps; ``missing`` is clipped at zero. NaN expected counts
70
+ flag identifiers with insufficient timing information.
71
+
72
+ ``include_empty_intervals`` adds bins between each identifier's first and
73
+ last observed bin for ordinary temporal frequencies, except when a bin
74
+ is shorter than the identifier's sampling cadence (unscheduled bins are
75
+ not counted as missing); it does not infer
76
+ missing deployment time before the first or after the last observation.
77
+ """
78
+ if not 0 <= min_completeness <= 1:
79
+ raise ValueError('min_completeness must be between 0 and 1')
80
+ frequency = _primary_frequency(frequency)
81
+ multiplier, unit = _parse_frequency(frequency)
82
+ values = list(value_cols)
83
+ if not values:
84
+ raise ValueError('value_cols must not be empty')
85
+ required = [id_col, time_col, *values]
86
+ absent = [name for name in required if name not in data.columns]
87
+ if absent:
88
+ raise KeyError(f'Missing completeness columns: {absent}')
89
+ frame = pd.DataFrame(data)[required].copy()
90
+ frame[time_col] = pd.to_datetime(frame[time_col], errors='coerce')
91
+ frame = frame.dropna(subset=[id_col, time_col])
92
+ columns = [id_col, 'interval', 'variable', 'observed', 'expected',
93
+ 'missing', 'completeness', 'is_complete', 'sampling_interval']
94
+ if frame.empty:
95
+ return pd.DataFrame(columns=columns)
96
+ frame['_interval'] = _interval_bins(frame[time_col], frequency)
97
+ # Nonpositive differences (duplicates) cannot describe a sampling cadence.
98
+ ordered = frame.sort_values([id_col, time_col])
99
+ diffs = ordered.groupby(id_col, sort=False)[time_col].diff()
100
+ cadence = (pd.DataFrame({id_col: ordered[id_col].to_numpy(),
101
+ 'gap': diffs.to_numpy()})
102
+ .loc[lambda x: x.gap > pd.Timedelta(0)]
103
+ .groupby(id_col)['gap'].median())
104
+ groups = frame.groupby([id_col, '_interval'], dropna=False, sort=True)[values].count()
105
+ if include_empty_intervals and unit != 'weekday':
106
+ duration = _DURATION[unit] * multiplier
107
+ new_parts = []
108
+ for identifier, portion in frame.groupby(id_col, sort=False):
109
+ first, last = portion['_interval'].min(), portion['_interval'].max()
110
+ # An interval shorter than the monitor cadence is not a scheduled
111
+ # observation interval. Do not manufacture missing sub-cadence bins.
112
+ monitor_cadence = cadence.get(identifier, pd.NaT)
113
+ if pd.notna(monitor_cadence) and duration < monitor_cadence:
114
+ bins = pd.DatetimeIndex(sorted(portion['_interval'].unique()))
115
+ else:
116
+ bins = pd.date_range(first, last, freq=duration)
117
+ new_parts.append(pd.MultiIndex.from_product([[identifier], bins], names=[id_col, '_interval']))
118
+ if new_parts:
119
+ all_bins = new_parts[0]
120
+ for additional in new_parts[1:]:
121
+ all_bins = all_bins.append(additional)
122
+ groups = groups.reindex(all_bins, fill_value=0)
123
+ long = (groups.rename_axis(index=[id_col, 'interval'])
124
+ .reset_index().melt(id_vars=[id_col, 'interval'],
125
+ value_vars=values, var_name='variable',
126
+ value_name='observed'))
127
+ long['sampling_interval'] = long[id_col].map(cadence)
128
+ expected = (_DURATION[unit] * multiplier) / long['sampling_interval']
129
+ expected = pd.to_numeric(expected, errors='coerce').round()
130
+ # A bin containing a recorded sample has one expected opportunity even
131
+ # when its width is smaller than the inferred sampling cadence.
132
+ # Such sub-cadence bins without records were excluded above.
133
+ long['expected'] = expected.clip(lower=1)
134
+ long['missing'] = (long['expected'] - long['observed']).clip(lower=0)
135
+ long['completeness'] = long['observed'] / long['expected']
136
+ long['is_complete'] = long['completeness'].ge(min_completeness) & long['expected'].notna()
137
+ return long[columns]
138
+
139
+
140
+ def complete_interval_mask(data, value_cols, frequency, *, time_col='datetime',
141
+ id_col='identifier', min_completeness=1.0):
142
+ """Row mask preserving legacy group_stat semantics (all values complete)."""
143
+ report = analyse_completeness(data, value_cols, frequency, time_col=time_col,
144
+ id_col=id_col, min_completeness=min_completeness)
145
+ complete = report.groupby([id_col, 'interval'])['is_complete'].all()
146
+ intervals = _interval_bins(pd.to_datetime(data[time_col]), _primary_frequency(frequency))
147
+ index = pd.MultiIndex.from_arrays([data[id_col], intervals], names=[id_col, 'interval'])
148
+ return pd.Series(index.isin(complete.index[complete]), index=data.index)
@@ -1,6 +1,5 @@
1
1
  """Plot configuration lifecycle: reactive drafts and explicitly retained presets."""
2
2
  from __future__ import annotations
3
- from .shiny.profiling import traced
4
3
  import copy
5
4
  import json
6
5
  import uuid
@@ -161,6 +160,31 @@ class PlotConfigManager:
161
160
  self.revision += 1
162
161
  return preset_id
163
162
 
163
+ def retain_quality(self, name, settings, *, preset_id=None):
164
+ """Retain a quality dashboard configuration, never computed report data."""
165
+ name = str(name).strip()
166
+ if not name:
167
+ raise ValueError('Configuration name must not be empty')
168
+ preset_id = preset_id or uuid.uuid4().hex
169
+ item = {'schema_version': 1, 'id': preset_id, 'name': name,
170
+ 'type': 'quality', 'settings': _json_ready(settings)}
171
+ items = self.saved()
172
+ for index, previous in enumerate(items):
173
+ if previous.get('id') == preset_id:
174
+ items[index] = item
175
+ break
176
+ else:
177
+ items.append(item)
178
+ self.summary.views = items
179
+ self.revision += 1
180
+ return preset_id
181
+
182
+ def restore_quality(self, preset_id):
183
+ item = next((p for p in self.saved() if p.get('id') == preset_id), None)
184
+ if item is None or item.get('type') != 'quality':
185
+ raise KeyError(preset_id)
186
+ return copy.deepcopy(item['settings'])
187
+
164
188
  def restore_table(self, preset_id):
165
189
  item = next((p for p in self.saved() if p.get('id') == preset_id), None)
166
190
  if item is None or item.get('type') != 'table':
@@ -172,7 +196,7 @@ class PlotConfigManager:
172
196
  def delete_saved(self, preset_id):
173
197
  """Delete one explicitly saved plot by stable ID, not its open draft."""
174
198
  items = copy.deepcopy(self.summary.views or [])
175
- remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') in ('plot', 'table'))]
199
+ remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') in ('plot', 'table', 'quality'))]
176
200
  if len(remaining) == len(items):
177
201
  raise KeyError(f'Unknown saved plot: {preset_id}')
178
202
  self.summary.views = remaining
@@ -0,0 +1,96 @@
1
+ """Headless data-quality summaries and heatmap preparation.
2
+
3
+ The dashboard never infers completeness independently of the Stage 4A engine.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import pandas as pd
8
+ from ..completeness import analyse_completeness
9
+
10
+ QUALITY_FREQUENCIES = ('minute', 'hour', 'day', 'week')
11
+
12
+
13
+ def quality_report(data, settings):
14
+ """Recompute per-identifier, interval and variable coverage from source data."""
15
+ values = list(settings.get('value_cols', []))
16
+ if not values:
17
+ raise ValueError('Select at least one measurement variable')
18
+ freq = settings.get('frequency', 'hour')
19
+ if freq not in QUALITY_FREQUENCIES:
20
+ raise ValueError(f'Unsupported dashboard frequency: {freq}')
21
+ return analyse_completeness(
22
+ data, values, freq,
23
+ id_col=settings.get('id_col', 'identifier'),
24
+ time_col=settings.get('time_col', 'datetime'),
25
+ min_completeness=float(settings.get('min_completeness', 1.0)),
26
+ include_empty_intervals=bool(settings.get('include_empty_intervals', True)),
27
+ )
28
+
29
+
30
+ def quality_summary(report):
31
+ """Per-monitor and variable coverage, including zero-observation bins."""
32
+ names = ['identifier', 'variable', 'intervals', 'complete_intervals',
33
+ 'observed', 'expected', 'missing', 'coverage_percent',
34
+ 'complete_percent']
35
+ if report.empty:
36
+ return pd.DataFrame(columns=names)
37
+ key = report.columns[0]
38
+ stats = (report.groupby([key, 'variable'], dropna=False, sort=True)
39
+ .agg(intervals=('is_complete', 'size'),
40
+ complete_intervals=('is_complete', 'sum'),
41
+ observed=('observed', 'sum'),
42
+ expected=('expected', 'sum'),
43
+ missing=('missing', 'sum')).reset_index())
44
+ stats['coverage_percent'] = (100 * stats['observed'] / stats['expected']).where(stats['expected'] > 0)
45
+ stats['complete_percent'] = 100 * stats['complete_intervals'] / stats['intervals']
46
+ return stats.rename(columns={key: 'identifier'})[names]
47
+
48
+
49
+ def selected_heatmap_variable(value_cols, selected):
50
+ """Return a heatmap variable present in the coverage report.
51
+
52
+ Changing the selected measurements cannot leave the heatmap looking for a
53
+ numeric column that was not included in the report.
54
+ """
55
+ choices = list(value_cols or [])
56
+ return selected if selected in choices else (choices[0] if choices else None)
57
+
58
+
59
+ def quality_heatmap_data(report, variable):
60
+ """Interval coverage matrix (identifiers x bins); missing data remain NaN."""
61
+ if report.empty:
62
+ return pd.DataFrame()
63
+ key = report.columns[0]
64
+ portion = report.loc[report['variable'].eq(variable), [key, 'interval', 'completeness']].copy()
65
+ if portion.empty:
66
+ return pd.DataFrame()
67
+ return portion.pivot_table(index=key, columns='interval', values='completeness', aggfunc='mean', dropna=False)
68
+
69
+
70
+ def missing_interval_runs(report):
71
+ """Group consecutive zero-observation bins into gap episodes per monitor/variable.
72
+
73
+ A new episode begins whenever an observed bin separates missing bins.
74
+ The report must include empty intervals to expose fully absent time bins.
75
+ """
76
+ fields = ['identifier', 'variable', 'first_missing_interval', 'last_missing_interval',
77
+ 'missing_intervals', 'expected_observations']
78
+ if report.empty:
79
+ return pd.DataFrame(columns=fields)
80
+ key = report.columns[0]
81
+ rows = []
82
+ for (identifier, variable), group in report.groupby([key, 'variable'], sort=False, dropna=False):
83
+ ordered = group.sort_values('interval')
84
+ run = []
85
+ def finish():
86
+ if run:
87
+ rows.append((identifier, variable, run[0]['interval'], run[-1]['interval'],
88
+ len(run), sum(record['expected'] for record in run)))
89
+ for record in ordered.to_dict('records'):
90
+ if record['observed'] == 0:
91
+ run.append(record)
92
+ else:
93
+ finish()
94
+ run = []
95
+ finish()
96
+ return pd.DataFrame(rows, columns=fields)
@@ -15,6 +15,7 @@ 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_view import quality_report, quality_summary, quality_heatmap_data, missing_interval_runs, QUALITY_FREQUENCIES, selected_heatmap_variable
18
19
  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
19
20
  from .elements import element_keys, filter_elements, default_visible_keys
20
21
  from .export import _safe_csv_filename, _standalone_matplotlib_script
@@ -168,8 +169,8 @@ def _build_summary_ui_server(
168
169
  "plots and panel titles (Matplotlib backend)."),
169
170
  ),
170
171
  ui.h4("Plot tabs, export and application theme"),
171
- ui.p("Use + New plot to add a plot tab, or Close selected plot to "
172
- "remove the current plot. The Info tab remains available."),
172
+ ui.p("Use New plot, New table, or New quality to open independent views. "
173
+ "Close tab removes the selected view; the Info tab remains available."),
173
174
  ui.p("Export plotted data as CSV saves the data displayed in the plot. "
174
175
  "Export plot saves the chart. With the Matplotlib backend, "
175
176
  "Export as a Python file and associated data creates a "
@@ -2030,6 +2031,166 @@ if (!window.__apmColorPickerInstalled) {
2030
2031
  return snapshot
2031
2032
 
2032
2033
 
2034
+ # Quality dashboards have independent controls and do not modify plot data.
2035
+ @module.ui
2036
+ def quality_tab_ui(settings=None):
2037
+ 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]
2041
+ selected_heatmap = selected_heatmap_variable(selected_values, settings.get("heatmap_variable"))
2042
+ return ui.div(
2043
+ ui.layout_sidebar(
2044
+ ui.sidebar(
2045
+ ui.h4("Coverage analysis"),
2046
+ ui.input_select("id_col", "Monitor / identifier column",
2047
+ choices=id_choices,
2048
+ selected=settings.get("id_col", "identifier") if settings.get("id_col", "identifier") in id_choices else (id_choices[0] if id_choices else None)),
2049
+ ui.input_select("time_col", "Timestamp column",
2050
+ choices=times,
2051
+ selected=settings.get("time_col", "datetime") if settings.get("time_col", "datetime") in times else (times[0] if times else None)),
2052
+ ui.input_selectize("value_cols", "Measurements",
2053
+ choices=numeric_columns, multiple=True,
2054
+ selected=selected_values),
2055
+ ui.input_select("frequency", "Aggregation frequency",
2056
+ choices={v: v.capitalize() for v in QUALITY_FREQUENCIES},
2057
+ selected=settings.get("frequency", "hour")),
2058
+ ui.input_numeric("min_completeness", "Minimum completeness (0–1)",
2059
+ value=settings.get("min_completeness", 1.0), min=0, max=1, step=0.05),
2060
+ ui.input_checkbox("include_empty_intervals", "Include entirely missing intervals",
2061
+ value=settings.get("include_empty_intervals", True)),
2062
+ ui.input_select("preview_rows", "Interval table preview",
2063
+ choices={"500": "500 rows", "1000": "1,000 rows", "5000": "5,000 rows", "all": "All rows"},
2064
+ selected="1000"),
2065
+ ui.input_select("heatmap_variable", "Heatmap measurement",
2066
+ choices={v: v for v in selected_values},
2067
+ selected=selected_heatmap),
2068
+ ui.p("Coverage is estimated from each monitor's observed sampling cadence. "
2069
+ "Intervals outside the recording span cannot be inferred.",
2070
+ class_="small text-muted"),
2071
+ ui.download_button("download_quality_csv", "Export interval coverage CSV"),
2072
+ title="Data quality", position="left", open="open", width="320px",
2073
+ ),
2074
+ ui.div(
2075
+ ui.output_ui("quality_status"),
2076
+ ui.h4("Coverage by monitor and measurement"),
2077
+ ui.output_data_frame("quality_summary_grid"),
2078
+ ui.h4("Completeness heatmap"),
2079
+ ui.output_plot("quality_heatmap", height="420px"),
2080
+ ui.h4("Entirely missing interval sequences"),
2081
+ ui.output_data_frame("quality_gaps_grid"),
2082
+ ui.h4("Individual intervals"),
2083
+ ui.output_data_frame("quality_intervals_grid"),
2084
+ class_="p-3", style="min-width: 0; width: 100%;",
2085
+ ),
2086
+ ), class_="summary-quality-tab",
2087
+ )
2088
+
2089
+ @module.server
2090
+ def quality_tab_server(input, output, session, settings=None):
2091
+ settings = settings or {}
2092
+
2093
+ @reactive.calc
2094
+ def report_settings():
2095
+ # Keep heatmap-only edits out of the expensive coverage calculation.
2096
+ return {
2097
+ "id_col": input.id_col(),
2098
+ "time_col": input.time_col(),
2099
+ "value_cols": list(input.value_cols() or []),
2100
+ "frequency": input.frequency(),
2101
+ "min_completeness": float(input.min_completeness() if input.min_completeness() is not None else 1.0),
2102
+ "include_empty_intervals": bool(input.include_empty_intervals()),
2103
+ }
2104
+
2105
+ @reactive.effect
2106
+ @reactive.event(input.value_cols)
2107
+ def sync_heatmap_choices():
2108
+ # Only selected measurement variables appear in the report. Keep
2109
+ # the heatmap selector in sync, but never recreate its input.
2110
+ choices = list(input.value_cols() or [])
2111
+ with reactive.isolate():
2112
+ selected = input.heatmap_variable()
2113
+ ui.update_select("heatmap_variable", choices={v: v for v in choices},
2114
+ selected=selected_heatmap_variable(choices, selected) or "")
2115
+
2116
+ @reactive.calc
2117
+ def quality_settings():
2118
+ conf = report_settings()
2119
+ return {**conf, "heatmap_variable": selected_heatmap_variable(
2120
+ conf["value_cols"], input.heatmap_variable())}
2121
+
2122
+ @reactive.calc
2123
+ def report():
2124
+ conf = report_settings()
2125
+ if not conf["value_cols"] or not conf["id_col"] or not conf["time_col"]:
2126
+ return pd.DataFrame()
2127
+ return quality_report(summary, conf)
2128
+
2129
+ @render.ui
2130
+ def quality_status():
2131
+ try:
2132
+ result = report()
2133
+ if result.empty:
2134
+ return ui.p("Select a measurement to analyse coverage.", class_="text-muted")
2135
+ missing_bins = int((result["observed"] == 0).sum())
2136
+ return ui.p(f"{len(result):,} measurement intervals; "
2137
+ f"{int(result['is_complete'].sum()):,} complete; "
2138
+ f"{missing_bins:,} entirely missing.", class_="text-muted")
2139
+ except (ValueError, KeyError) as exc:
2140
+ return ui.p(str(exc), class_="text-danger")
2141
+
2142
+ @render.data_frame
2143
+ def quality_summary_grid():
2144
+ return render.DataGrid(quality_summary(report()), filters=True, width="100%", height="230px")
2145
+
2146
+ @render.data_frame
2147
+ def quality_gaps_grid():
2148
+ return render.DataGrid(missing_interval_runs(report()), filters=True, width="100%", height="190px")
2149
+
2150
+ @render.data_frame
2151
+ def quality_intervals_grid():
2152
+ data = report()
2153
+ preview = input.preview_rows()
2154
+ if preview != 'all':
2155
+ data = data.head(int(preview))
2156
+ data = data.copy()
2157
+ if 'sampling_interval' in data:
2158
+ data['sampling_interval'] = data['sampling_interval'].astype(str)
2159
+ return render.DataGrid(data, filters=True, width="100%", height="320px")
2160
+
2161
+ @render.plot
2162
+ def quality_heatmap():
2163
+ import matplotlib.pyplot as plt
2164
+ from matplotlib.colors import Normalize
2165
+ conf = quality_settings()
2166
+ matrix = quality_heatmap_data(report(), conf["heatmap_variable"])
2167
+ fig, ax = plt.subplots(figsize=(10, 4))
2168
+ if matrix.empty:
2169
+ ax.text(0.5, 0.5, "No data for selected measurement", ha="center", va="center")
2170
+ ax.set_axis_off()
2171
+ return fig
2172
+ colors = plt.get_cmap("viridis").copy()
2173
+ colors.set_bad("lightgray")
2174
+ image = ax.imshow(matrix.to_numpy(dtype=float), aspect="auto", interpolation="nearest",
2175
+ cmap=colors, norm=Normalize(vmin=0, vmax=1))
2176
+ ax.set_yticks(range(len(matrix.index)))
2177
+ ax.set_yticklabels([str(v) for v in matrix.index], fontsize=8)
2178
+ ticks = sorted(set([0, len(matrix.columns) - 1] + list(range(0, len(matrix.columns), max(1, len(matrix.columns) // 8)))))
2179
+ ax.set_xticks(ticks)
2180
+ ax.set_xticklabels([str(matrix.columns[i]) for i in ticks], rotation=35, ha="right", fontsize=8)
2181
+ ax.set_xlabel("Aggregation interval")
2182
+ ax.set_ylabel(conf["id_col"])
2183
+ ax.set_title(f"{conf['heatmap_variable']} coverage")
2184
+ fig.colorbar(image, ax=ax, label="Observed / expected")
2185
+ fig.tight_layout()
2186
+ return fig
2187
+
2188
+ @render.download(filename="completeness_intervals.csv")
2189
+ def download_quality_csv():
2190
+ yield report().to_csv(index=False)
2191
+
2192
+ return quality_settings
2193
+
2033
2194
  @module.ui
2034
2195
  def table_tab_ui(frame, settings=None):
2035
2196
  settings = settings or {}
@@ -2517,6 +2678,7 @@ if (!window.__apmColorPickerInstalled) {
2517
2678
  ui.div(
2518
2679
  ui.input_action_button("add_tab", "New plot", class_="btn-primary"),
2519
2680
  ui.input_action_button("add_table", "New table", class_="btn-primary"),
2681
+ ui.input_action_button("add_quality", "New quality", class_="btn-primary"),
2520
2682
  ui.input_action_button("retain_plot", "Save", class_="btn-outline-primary"),
2521
2683
  ui.input_action_button("restore_plot", "Load", class_="btn-outline-primary"),
2522
2684
  ui.input_action_button("undo_view", "Undo", class_="btn-outline-secondary"),
@@ -2557,6 +2719,7 @@ if (!window.__apmColorPickerInstalled) {
2557
2719
  table_settings_by_tab = workspace.table_settings
2558
2720
  table_source_steps = workspace.table_lineage
2559
2721
  next_table = reactive.value(0)
2722
+ next_quality = reactive.value(0)
2560
2723
  from ..plot_configs import PlotConfigManager
2561
2724
  manager = config_manager or PlotConfigManager(data)
2562
2725
  views = ViewController(workspace, manager)
@@ -2596,6 +2759,22 @@ if (!window.__apmColorPickerInstalled) {
2596
2759
  except (TypeError, ValueError, KeyError):
2597
2760
  pass
2598
2761
 
2762
+ def register_quality_history(tab_id):
2763
+ first = True
2764
+ @reactive.effect
2765
+ def sync_quality_history():
2766
+ nonlocal first
2767
+ if workspace.kind(tab_id) != 'quality':
2768
+ return
2769
+ try:
2770
+ state = dict(workspace.quality_settings[tab_id]())
2771
+ if first:
2772
+ first = False
2773
+ elif views.record_edit(tab_id, state):
2774
+ refresh_history()
2775
+ except (TypeError, ValueError, KeyError):
2776
+ pass
2777
+
2599
2778
  @render.ui
2600
2779
  def view_edit_status():
2601
2780
  history_revision.get()
@@ -2632,7 +2811,7 @@ if (!window.__apmColorPickerInstalled) {
2632
2811
  @traced('retain_selected', kind='reactive_effect', trigger='@reactive.event(input.retain_plot)')
2633
2812
  def retain_selected():
2634
2813
  selected = input.plot_tabs()
2635
- if selected not in tab_filters and selected not in table_frames:
2814
+ if selected not in tab_filters and selected not in table_frames and selected not in workspace.quality_settings:
2636
2815
  ui.notification_show("Select a plot or table tab to save", type="warning")
2637
2816
  return
2638
2817
  ui.modal_show(ui.modal(
@@ -2827,6 +3006,21 @@ if (!window.__apmColorPickerInstalled) {
2827
3006
  ui.insert_nav_panel("plot_tabs", panel, select=True)
2828
3007
  ui.modal_remove()
2829
3008
  return
3009
+ if preset.get("type") == "quality":
3010
+ number = next_quality.get() + 1
3011
+ tab_id = f"quality_{number}"
3012
+ settings = views.restore_quality(preset_id)
3013
+ 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)
3016
+ workspace.add_quality(tab_id, snapshot)
3017
+ begin_history(tab_id, settings, restored=True)
3018
+ register_quality_history(tab_id)
3019
+ next_quality.set(number)
3020
+ active_tabs.set(workspace.active_tabs)
3021
+ ui.insert_nav_panel("plot_tabs", panel, select=True)
3022
+ ui.modal_remove()
3023
+ return
2830
3024
  number = next_tab.get() + 1
2831
3025
  tab_id = f"plot_{number}"
2832
3026
  initialized = initializer.restored_plot(preset_id, tab_id, number)
@@ -2860,6 +3054,20 @@ if (!window.__apmColorPickerInstalled) {
2860
3054
  active_tabs.set(workspace.active_tabs)
2861
3055
  ui.insert_nav_panel("plot_tabs", new_panel, select=True)
2862
3056
 
3057
+ @reactive.effect
3058
+ @reactive.event(input.add_quality)
3059
+ def add_quality():
3060
+ number = next_quality.get() + 1
3061
+ 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)
3064
+ workspace.add_quality(tab_id, settings)
3065
+ begin_history(tab_id, {})
3066
+ register_quality_history(tab_id)
3067
+ next_quality.set(number)
3068
+ active_tabs.set(workspace.active_tabs)
3069
+ ui.insert_nav_panel("plot_tabs", panel, select=True)
3070
+
2863
3071
  @reactive.effect
2864
3072
  @reactive.event(input.add_table)
2865
3073
  @traced('add_table', kind='reactive_effect', trigger='@reactive.event(input.add_table)')
@@ -2895,7 +3103,15 @@ if (!window.__apmColorPickerInstalled) {
2895
3103
  # initialized consistently, rather than patching only inputs
2896
3104
  # that happen to be visible. Preserve the same history stack.
2897
3105
  kind = workspace.kind(selected)
2898
- if kind == "plot":
3106
+ if kind == "quality":
3107
+ number = next_quality.get() + 1
3108
+ next_quality.set(number)
3109
+ 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)
3112
+ workspace.add_quality(replacement, quality_snapshot)
3113
+ register_quality_history(replacement)
3114
+ elif kind == "plot":
2899
3115
  number = next_tab.get() + 1
2900
3116
  next_tab.set(number)
2901
3117
  replacement = f"plot_{number}"
@@ -42,11 +42,11 @@ class ViewController:
42
42
 
43
43
  def saved(self):
44
44
  """Return independently mutable copies of both saved view types."""
45
- return [p for p in self.manager.saved() if p.get('type', 'plot') in ('plot', 'table')]
45
+ return [p for p in self.manager.saved() if p.get('type', 'plot') in ('plot', 'table', 'quality')]
46
46
 
47
47
  def choices(self):
48
48
  """Stable ID -> human-readable label, independent of the active tab."""
49
- return {p['id']: f"{'Table' if p.get('type') == 'table' else 'Plot'}: {p.get('name', p['id'])}"
49
+ return {p['id']: f"{ {'table': 'Table', 'plot': 'Plot', 'quality': 'Quality'}.get(p.get('type', 'plot'), 'Plot')}: {p.get('name', p['id'])}"
50
50
  for p in self.saved()}
51
51
 
52
52
  def preset(self, preset_id):
@@ -72,7 +72,18 @@ class ViewController:
72
72
  if tab_id in self.history._tabs:
73
73
  self.history.mark_saved(tab_id, settings)
74
74
  return preset_id
75
- raise ValueError('No active plot or table tab')
75
+ if kind == 'quality':
76
+ settings = copy.deepcopy(self.workspace.quality_settings[tab_id]())
77
+ preset_id = self.manager.retain_quality(name, settings)
78
+ if tab_id in self.history._tabs:
79
+ self.history.mark_saved(tab_id, settings)
80
+ return preset_id
81
+ raise ValueError('No active plot, table or quality tab')
82
+
83
+ def restore_quality(self, preset_id):
84
+ if self.preset(preset_id).get('type') != 'quality':
85
+ raise ValueError('Saved configuration is not a quality dashboard')
86
+ return self.manager.restore_quality(preset_id)
76
87
 
77
88
  def delete(self, preset_id):
78
89
  """Delete a retained view without affecting any open tab."""
@@ -11,6 +11,7 @@ class WorkspaceController:
11
11
 
12
12
  def __init__(self):
13
13
  self.plot_snapshots = {}
14
+ self.quality_settings = {}
14
15
  self.table_frames = {}
15
16
  self.table_settings = {}
16
17
  self.table_lineage = {}
@@ -19,6 +20,8 @@ class WorkspaceController:
19
20
  def kind(self, tab_id):
20
21
  if tab_id in self.plot_snapshots:
21
22
  return 'plot'
23
+ if tab_id in self.quality_settings:
24
+ return 'quality'
22
25
  if tab_id in self.table_frames:
23
26
  return 'table'
24
27
  return None
@@ -41,9 +44,18 @@ class WorkspaceController:
41
44
  self.active_tabs += (tab_id,)
42
45
  return self.active_tabs
43
46
 
47
+ def add_quality(self, tab_id, settings):
48
+ self._check_new(tab_id)
49
+ if not callable(settings):
50
+ raise TypeError('Quality settings must be callable')
51
+ self.quality_settings[tab_id] = settings
52
+ self.active_tabs += (tab_id,)
53
+ return self.active_tabs
54
+
44
55
  def remove(self, tab_id):
45
56
  if tab_id not in self.active_tabs:
46
57
  return False
58
+ self.quality_settings.pop(tab_id, None)
47
59
  self.plot_snapshots.pop(tab_id, None)
48
60
  self.table_frames.pop(tab_id, None)
49
61
  self.table_settings.pop(tab_id, None)
@@ -38,7 +38,7 @@ def validate_saved_views(views):
38
38
  for index, item in enumerate(views):
39
39
  if not isinstance(item, dict):
40
40
  raise ValueError(f'Saved view {index} must be a dictionary')
41
- if item.get('schema_version') != CURRENT_SCHEMA_VERSION or item.get('type') not in ('plot', 'table'):
41
+ if item.get('schema_version') != CURRENT_SCHEMA_VERSION or item.get('type') not in ('plot', 'table', 'quality'):
42
42
  raise ValueError(f'Unsupported saved view configuration at index {index}')
43
43
  if not isinstance(item.get('settings'), dict):
44
44
  raise ValueError(f'Saved view settings must be a dictionary at index {index}')
File without changes
File without changes
File without changes