apmtools 0.2.56__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.56 → apmtools-0.2.58}/PKG-INFO +1 -1
  2. {apmtools-0.2.56 → apmtools-0.2.58}/pyproject.toml +1 -1
  3. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/classes.py +38 -200
  4. apmtools-0.2.58/src/apmtools/completeness.py +148 -0
  5. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/plot_configs.py +31 -26
  6. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/data.py +7 -7
  7. apmtools-0.2.58/src/apmtools/shiny/derived_views.py +35 -0
  8. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/inherited_source.py +29 -9
  9. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/matplotlib_renderer.py +13 -8
  10. apmtools-0.2.58/src/apmtools/shiny/multi_stat_plot.py +53 -0
  11. apmtools-0.2.58/src/apmtools/shiny/profile_report.py +51 -0
  12. apmtools-0.2.58/src/apmtools/shiny/profiling.py +103 -0
  13. apmtools-0.2.58/src/apmtools/shiny/quality_view.py +96 -0
  14. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/state.py +14 -0
  15. apmtools-0.2.58/src/apmtools/shiny/tab_initialization.py +74 -0
  16. apmtools-0.2.58/src/apmtools/shiny/text_editors.py +23 -0
  17. apmtools-0.2.58/src/apmtools/shiny/transformations.py +208 -0
  18. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/ui.py +704 -233
  19. apmtools-0.2.58/src/apmtools/shiny/view_aggregation.py +124 -0
  20. apmtools-0.2.58/src/apmtools/shiny/view_controller.py +102 -0
  21. apmtools-0.2.58/src/apmtools/shiny/view_history.py +106 -0
  22. apmtools-0.2.58/src/apmtools/shiny/workspace_controller.py +70 -0
  23. apmtools-0.2.58/src/apmtools/view_schema.py +106 -0
  24. {apmtools-0.2.56 → apmtools-0.2.58}/.gitignore +0 -0
  25. {apmtools-0.2.56 → apmtools-0.2.58}/LICENSE +0 -0
  26. {apmtools-0.2.56 → apmtools-0.2.58}/README.md +0 -0
  27. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/__init__.py +0 -0
  28. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/data_processing.py +0 -0
  29. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/functions.py +0 -0
  30. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/legacy.py +0 -0
  31. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/plot_app.py +0 -0
  32. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/__init__.py +0 -0
  33. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/appearance.py +0 -0
  34. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/color_editor.py +0 -0
  35. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/columns.py +0 -0
  36. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/config.py +0 -0
  37. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/elements.py +0 -0
  38. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/export.py +0 -0
  39. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/filter_config.py +0 -0
  40. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/histogram_multi.py +0 -0
  41. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/legend_identity.py +0 -0
  42. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/multi_axis.py +0 -0
  43. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/multi_y.py +0 -0
  44. {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/summary.py +0 -0
  45. {apmtools-0.2.56 → 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.56
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.56"
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
  """
@@ -1224,179 +1238,21 @@ class Summary(pd.DataFrame):
1224
1238
  print(f"{v} not in Summary columns")
1225
1239
  return None
1226
1240
 
1227
- df = self.dropna(subset=[time_col]).copy()
1241
+ # Non-temporal grouping also works on previously aggregated frames.
1242
+ # Only operations involving a frequency need the source time column.
1243
+ if frequency is not None and time_col not in self.columns:
1244
+ raise KeyError(f"Temporal aggregation requires column {time_col!r}")
1245
+ df = (self.dropna(subset=[time_col]).copy()
1246
+ if frequency is not None else self.copy())
1228
1247
 
1229
1248
  if frequency != None:
1230
1249
 
1231
1250
  if only_complete:
1232
- def median_interval(g):
1233
- return g.sort_values().diff().dropna().median()
1234
-
1235
- def sorting_frequency(x):
1236
- if len(list(filter(lambda z: True if "week" in z else False, x))) > 0:
1237
- return list(filter(lambda z: True if "week" in z else False, x))[0]
1238
- if len(list(filter(lambda z: True if "weekday" in z else False, x))) > 0:
1239
- return list(filter(lambda z: True if "weekday" in z else False, x))[0]
1240
- if len(list(filter(lambda z: True if "day" in z else False, x))) > 0:
1241
- return list(filter(lambda z: True if "day" in z else False, x))[0]
1242
- if len(list(filter(lambda z: True if "hour" in z else False, x))) > 0:
1243
- return list(filter(lambda z: True if "hour" in z else False, x))[0]
1244
- if len(list(filter(lambda z: True if "minute" in z else False, x))) > 0:
1245
- return list(filter(lambda z: True if "minute" in z else False, x))[0]
1246
- if len(list(filter(lambda z: True if "second" in z else False, x))) > 0:
1247
- return list(filter(lambda z: True if "second" in z else False, x))[0]
1248
-
1249
- def freq_ranked():
1250
- if isinstance(frequency, str):
1251
- return frequency
1252
- if isinstance(frequency, list):
1253
- return sorting_frequency(frequency)
1254
-
1255
- intervals = df.groupby(id_col)[time_col].apply(median_interval)
1256
- if isinstance(frequency, str):
1257
- if len(frequency.split(" ")) == 1:
1258
- match frequency:
1259
- case "hour":
1260
- expected = (pd.Timedelta(1, "hour") /
1261
- intervals).round().astype(int)
1262
- df["xxxyyyxxx"] = df["datetime"].dt.floor("h")
1263
- case "day":
1264
- expected = (pd.Timedelta(1, "day") /
1265
- intervals).round().astype(int)
1266
- df["xxxyyyxxx"] = df["datetime"].dt.floor("d")
1267
- case "minute":
1268
- expected = (pd.Timedelta(1, "minute") /
1269
- intervals).round().astype(int)
1270
- df["xxxyyyxxx"] = df["datetime"].dt.floor("min")
1271
- case "second":
1272
- expected = (pd.Timedelta(1, "second") /
1273
- intervals).round().astype(int)
1274
- df["xxxyyyxxx"] = df["datetime"].dt.floor("s")
1275
- case "week":
1276
- expected = (pd.Timedelta(1, "W") /
1277
- intervals).round().astype(int)
1278
- df["xxxyyyxxx"] = df["datetime"].dt.floor("w")
1279
- case "weekday":
1280
- expected = (pd.Timedelta(1, "day") /
1281
- intervals).round().astype(int)
1282
- df["xxxyyyxxx"] = df["datetime"].map(
1283
- lambda x: (x.weekday()))
1284
- if len(frequency.split(" ")) == 2:
1285
- mult = float(frequency.split(" ")[0])
1286
- match frequency.split(" ")[1]:
1287
- case "hour":
1288
- expected = (pd.Timedelta(mult, "hour") /
1289
- intervals).round().astype(int)
1290
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1291
- f"{mult}h")
1292
- case "day":
1293
- expected = (pd.Timedelta(mult, "day") /
1294
- intervals).round().astype(int)
1295
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1296
- f"{mult}d")
1297
- case "minute":
1298
- expected = (pd.Timedelta(mult, "minute") /
1299
- intervals).round().astype(int)
1300
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1301
- f"{mult}min")
1302
- case "second":
1303
- expected = (pd.Timedelta(mult, "second") /
1304
- intervals).round().astype(int)
1305
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1306
- f"{mult}s")
1307
- case "week":
1308
- expected = (pd.Timedelta(mult, "W") /
1309
- intervals).round().astype(int)
1310
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1311
- f"{mult}w")
1312
- case "weekday":
1313
- expected = (pd.Timedelta(mult, "day") /
1314
- intervals).round().astype(int)
1315
- df["xxxyyyxxx"] = df["datetime"].map(
1316
- lambda x: (x.weekday//mult))*mult
1317
-
1318
- counts = df.groupby([id_col, "xxxyyyxxx"])[
1319
- value_cols].count()
1320
- expe = counts.index.get_level_values(id_col).map(expected)
1321
- completeness = counts.div(expe, axis=0)
1322
- complete_hours = completeness[completeness >= min_completeness].dropna(
1323
- ).index
1324
- mask = pd.MultiIndex.from_arrays(
1325
- [df[id_col], df["xxxyyyxxx"]]).isin(complete_hours)
1326
- df = df.loc[mask]
1327
-
1328
- if isinstance(frequency, list):
1329
- freq = freq_ranked()
1330
- if len(freq.split(" ")) == 1:
1331
- match freq:
1332
- case "hour":
1333
- expected = (pd.Timedelta(1, "hour") /
1334
- intervals).round().astype(int)
1335
- df["xxxyyyxxx"] = df["datetime"].dt.floor("h")
1336
- case "day":
1337
- expected = (pd.Timedelta(1, "day") /
1338
- intervals).round().astype(int)
1339
- df["xxxyyyxxx"] = df["datetime"].dt.floor("d")
1340
- case "minute":
1341
- expected = (pd.Timedelta(1, "minute") /
1342
- intervals).round().astype(int)
1343
- df["xxxyyyxxx"] = df["datetime"].dt.floor("min")
1344
- case "second":
1345
- expected = (pd.Timedelta(1, "second") /
1346
- intervals).round().astype(int)
1347
- df["xxxyyyxxx"] = df["datetime"].dt.floor("s")
1348
- case "week":
1349
- expected = (pd.Timedelta(1, "W") /
1350
- intervals).round().astype(int)
1351
- df["xxxyyyxxx"] = df["datetime"].dt.floor("w")
1352
- case "weekday":
1353
- expected = (pd.Timedelta(1, "day") /
1354
- intervals).round().astype(int)
1355
- df["xxxyyyxxx"] = df["datetime"].map(
1356
- lambda x: (x.weekday()))
1357
- if len(freq.split(" ")) == 2:
1358
- mult = float(freq.split(" ")[0])
1359
- match freq.split(" ")[1]:
1360
- case "hour":
1361
- expected = (pd.Timedelta(mult, "hour") /
1362
- intervals).round().astype(int)
1363
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1364
- f"{mult}h")
1365
- case "day":
1366
- expected = (pd.Timedelta(mult, "day") /
1367
- intervals).round().astype(int)
1368
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1369
- f"{mult}d")
1370
- case "minute":
1371
- expected = (pd.Timedelta(mult, "minute") /
1372
- intervals).round().astype(int)
1373
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1374
- f"{mult}m")
1375
- case "second":
1376
- expected = (pd.Timedelta(mult, "second") /
1377
- intervals).round().astype(int)
1378
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1379
- f"{mult}s")
1380
- case "week":
1381
- expected = (pd.Timedelta(mult, "W") /
1382
- intervals).round().astype(int)
1383
- df["xxxyyyxxx"] = df["datetime"].dt.floor(
1384
- f"{mult}w")
1385
- case "weekday":
1386
- expected = (pd.Timedelta(mult, "day") /
1387
- intervals).round().astype(int)
1388
- df["xxxyyyxxx"] = df["datetime"].map(
1389
- lambda x: (x.weekday//mult))*mult
1390
-
1391
- counts = df.groupby([id_col, "xxxyyyxxx"])[
1392
- value_cols].count()
1393
- expe = counts.index.get_level_values(id_col).map(expected)
1394
- completeness = counts.div(expe, axis=0)
1395
- complete_hours = completeness[completeness >= min_completeness].dropna(
1396
- ).index
1397
- mask = pd.MultiIndex.from_arrays(
1398
- [df[id_col], df["xxxyyyxxx"]]).isin(complete_hours)
1399
- 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()
1400
1256
 
1401
1257
  if isinstance(frequency, str):
1402
1258
  if len(frequency.split(" ")) == 1:
@@ -1555,38 +1411,20 @@ class Summary(pd.DataFrame):
1555
1411
  df[freq] = df["datetime"].map(
1556
1412
  lambda x: (x.weekday//mult))*mult
1557
1413
 
1558
- if isinstance(frequency, str):
1559
- if grouping_by != None:
1560
- output = df.groupby(grouping_by+[frequency], dropna=False)
1561
- else:
1562
- output = df.groupby([frequency], dropna=False)
1563
- if isinstance(frequency, list):
1564
- if grouping_by != None:
1565
- output = df.groupby(grouping_by+frequency, dropna=False)
1566
- else:
1567
- output = df.groupby(frequency, dropna=False)
1568
- if frequency == None:
1569
- if grouping_by != None:
1570
- output = df.groupby(grouping_by, dropna=False)
1571
- else:
1572
- output = df
1414
+ # A frequency may also be an explicitly selected grouping variable.
1415
+ # pandas allows repeated group keys but reset_index() then attempts to
1416
+ # insert the same column twice (e.g. "cannot insert hour, already exists").
1417
+ # Preserve the requested order while grouping each key only once.
1418
+ grouping_keys = list(dict.fromkeys(
1419
+ list(grouping_by or []) +
1420
+ ([frequency] if isinstance(frequency, str) else
1421
+ list(frequency) if isinstance(frequency, list) else [])
1422
+ ))
1423
+ if frequency is not None or grouping_by is not None:
1424
+ output = df.groupby(grouping_keys, dropna=False) if aggregate is not None else df[grouping_keys]
1425
+ else:
1426
+ output = df
1573
1427
 
1574
- if isinstance(frequency, str) and aggregate is None:
1575
- if grouping_by != None:
1576
- output = df[grouping_by+[frequency]]
1577
- else:
1578
- output = df[[frequency]]
1579
- if isinstance(frequency, list) and aggregate is None:
1580
- if grouping_by != None:
1581
- output = df[grouping_by+frequency]
1582
- else:
1583
- output = df[frequency]
1584
- if frequency == None and aggregate is None:
1585
- if grouping_by != None:
1586
- output = df[grouping_by]
1587
- else:
1588
- output = df
1589
-
1590
1428
  if aggregate is not None:
1591
1429
  output = output[value_cols].agg(aggregate)
1592
1430
 
@@ -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)
@@ -12,27 +12,10 @@ def _json_ready(value):
12
12
 
13
13
 
14
14
  def validate_saved_plots(plots):
15
- """Validate the structure of retained presets without altering their values.
15
+ """Backward-compatible name for strict saved-view structural validation."""
16
+ from .view_schema import validate_saved_views
17
+ return validate_saved_views(plots)
16
18
 
17
- Old snapshots remain supported: unknown settings are deliberately retained.
18
- """
19
- if plots is None:
20
- return []
21
- if not isinstance(plots, list):
22
- raise TypeError("Summary.views must be a list or None")
23
- seen = set()
24
- for item in plots:
25
- if not isinstance(item, dict):
26
- raise ValueError("Saved plot entries must be dictionaries")
27
- if item.get("schema_version") != 1 or item.get("type") not in ("plot", "table"):
28
- raise ValueError("Unsupported saved plot configuration")
29
- if not isinstance(item.get("settings"), dict):
30
- raise ValueError("Saved plot settings must be a dictionary")
31
- identifier = item.get("id")
32
- if not isinstance(identifier, str) or not identifier or identifier in seen:
33
- raise ValueError("Saved plot IDs must be unique nonempty strings")
34
- seen.add(identifier)
35
- return copy.deepcopy(plots)
36
19
 
37
20
  class PlotConfigManager:
38
21
  """Keep live tab drafts separate from explicitly retained Summary.plots.
@@ -177,6 +160,31 @@ class PlotConfigManager:
177
160
  self.revision += 1
178
161
  return preset_id
179
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
+
180
188
  def restore_table(self, preset_id):
181
189
  item = next((p for p in self.saved() if p.get('id') == preset_id), None)
182
190
  if item is None or item.get('type') != 'table':
@@ -188,18 +196,15 @@ class PlotConfigManager:
188
196
  def delete_saved(self, preset_id):
189
197
  """Delete one explicitly saved plot by stable ID, not its open draft."""
190
198
  items = copy.deepcopy(self.summary.views or [])
191
- remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') in ('plot', 'table'))]
199
+ remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') in ('plot', 'table', 'quality'))]
192
200
  if len(remaining) == len(items):
193
201
  raise KeyError(f'Unknown saved plot: {preset_id}')
194
202
  self.summary.views = remaining
195
203
  self.revision += 1
196
204
 
197
205
  def saved(self):
198
- items = validate_saved_plots(self.summary.views)
199
- for item in items:
200
- if item.get('type') == 'table':
201
- item.pop('frame', None) # migrate legacy snapshot records
202
- return items
206
+ from .view_schema import migrate_saved_views
207
+ return migrate_saved_views(self.summary.views)
203
208
 
204
209
  def restore(self, preset_id, tab_id):
205
210
  item = next((p for p in self.saved() if p.get('id') == preset_id), None)
@@ -1,5 +1,6 @@
1
1
  """Filtering, aggregation and temporal segmentation."""
2
2
  from __future__ import annotations
3
+ from .profiling import traced
3
4
  import threading
4
5
  from datetime import date, datetime, time, timedelta
5
6
  from collections.abc import Sequence
@@ -82,6 +83,7 @@ def _parse_datetime_filter(value):
82
83
 
83
84
 
84
85
 
86
+ @traced('_apply_filters', kind="data" if 'shiny/data.py' not in ("plot_configs.py", "shiny/matplotlib_renderer.py") else "service")
85
87
  def _apply_filters(data, subset_filters, datetime_start=None, datetime_end=None):
86
88
  """Apply Summary filters, accepting dates, times and full datetimes.
87
89
 
@@ -123,6 +125,7 @@ def _apply_filters(data, subset_filters, datetime_start=None, datetime_end=None)
123
125
 
124
126
 
125
127
 
128
+ @traced('_aggregate', kind="data" if 'shiny/data.py' not in ("plot_configs.py", "shiny/matplotlib_renderer.py") else "service")
126
129
  def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
127
130
  only_complete=True, flooring_timestamp=True):
128
131
  """Aggregate through ``Summary.group_stat``.
@@ -138,13 +141,10 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
138
141
  # Return empty rather than breaking Shiny's reactive graph.
139
142
  return pd.DataFrame()
140
143
 
141
- result = data.group_stat(
142
- value_cols=value_columns,
143
- frequency=frequency,
144
- grouping_by=grouping_by,
145
- aggregate=[aggregate],
146
- **({"only_complete": only_complete, "flooring_timestamp": flooring_timestamp}
147
- if frequency is not None else {}),
144
+ from .view_aggregation import aggregate_view
145
+ result = aggregate_view(
146
+ data, value_columns, frequency, grouping_by, aggregate,
147
+ only_complete=only_complete, flooring_timestamp=flooring_timestamp,
148
148
  )
149
149
  if result is None:
150
150
  # group_stat() may return None when no rows survive completeness
@@ -0,0 +1,35 @@
1
+ """Headless, configuration-only table source preparation for Shiny tab actions.
2
+
3
+ The application creates tables from the owning Summary and ordered view
4
+ settings, never from snapshots of already rendered plot/table data.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import pandas as pd
9
+
10
+ from .inherited_source import source_frame, source_steps
11
+
12
+
13
+ def prepare_table_source(summary, *, parent_kind=None, parent_settings=None,
14
+ parent_steps=None, restored_settings=None):
15
+ """Return (display-ready frame, lineage steps) for new/loaded tables.
16
+
17
+ ``parent_kind`` is ``'plot'``, ``'table'`` or ``None`` (Info tab).
18
+ For restoration, pass ``restored_settings`` instead; its saved lineage is
19
+ replayed without modification. No parent DataFrame is copied or cached.
20
+ """
21
+ if restored_settings is not None:
22
+ steps = restored_settings.get('source_steps', [])
23
+ elif parent_kind in ('plot', 'table'):
24
+ if parent_settings is None:
25
+ raise ValueError('Parent view settings must be supplied')
26
+ steps = source_steps(parent_steps if parent_kind == 'table' else [],
27
+ parent_settings, parent_kind)
28
+ elif parent_kind is None:
29
+ steps = []
30
+ else:
31
+ raise ValueError(f'Unknown parent view kind: {parent_kind!r}')
32
+ frame = source_frame(summary, steps)
33
+ frame = frame.reset_index(drop=True)
34
+ frame.columns = [str(column) for column in frame.columns]
35
+ return frame, steps