apmtools 0.2.57__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.57 → apmtools-0.2.59}/PKG-INFO +1 -1
  2. {apmtools-0.2.57 → apmtools-0.2.59}/pyproject.toml +1 -1
  3. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/classes.py +19 -168
  4. apmtools-0.2.59/src/apmtools/completeness.py +148 -0
  5. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/plot_configs.py +26 -2
  6. apmtools-0.2.59/src/apmtools/shiny/quality_filters.py +76 -0
  7. apmtools-0.2.59/src/apmtools/shiny/quality_view.py +118 -0
  8. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/tab_initialization.py +20 -0
  9. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/ui.py +357 -4
  10. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/view_controller.py +14 -3
  11. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/workspace_controller.py +12 -0
  12. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/view_schema.py +2 -2
  13. {apmtools-0.2.57 → apmtools-0.2.59}/.gitignore +0 -0
  14. {apmtools-0.2.57 → apmtools-0.2.59}/LICENSE +0 -0
  15. {apmtools-0.2.57 → apmtools-0.2.59}/README.md +0 -0
  16. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/__init__.py +0 -0
  17. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/data_processing.py +0 -0
  18. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/functions.py +0 -0
  19. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/legacy.py +0 -0
  20. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/plot_app.py +0 -0
  21. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/__init__.py +0 -0
  22. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/appearance.py +0 -0
  23. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/color_editor.py +0 -0
  24. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/columns.py +0 -0
  25. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/config.py +0 -0
  26. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/data.py +0 -0
  27. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/derived_views.py +0 -0
  28. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/elements.py +0 -0
  29. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/export.py +0 -0
  30. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/filter_config.py +0 -0
  31. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/histogram_multi.py +0 -0
  32. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/inherited_source.py +0 -0
  33. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/legend_identity.py +0 -0
  34. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
  35. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/multi_axis.py +0 -0
  36. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/multi_stat_plot.py +0 -0
  37. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/multi_y.py +0 -0
  38. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/profile_report.py +0 -0
  39. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/profiling.py +0 -0
  40. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/state.py +0 -0
  41. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/summary.py +0 -0
  42. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/text_editors.py +0 -0
  43. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/transformations.py +0 -0
  44. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/view_aggregation.py +0 -0
  45. {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/view_history.py +0 -0
  46. {apmtools-0.2.57 → 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.57
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.57"
7
+ version = "0.2.59"
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,76 @@
1
+ """Reusable, Shiny-independent subset filtering for quality dashboards."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from datetime import datetime, time, timedelta
8
+ from .data import _parse_datetime_filter, _subset_choices
9
+
10
+
11
+ def filter_quality_data(frame, settings):
12
+ """Apply the same staged categorical, numeric and date filters as tables.
13
+
14
+ The configured selected values are Shiny selector *keys*, resolved against
15
+ the source dataframe. Missing numeric values remain unchanged, consistent
16
+ with the existing table filtering policy.
17
+ """
18
+ current = frame.copy()
19
+ for step in settings.get('subset_chain', []):
20
+ column = step.get('column')
21
+ if column not in current.columns:
22
+ continue
23
+ _, value_map = _subset_choices(frame, column)
24
+ values = [value_map[key] for key in step.get('values', []) if key in value_map]
25
+ if not values:
26
+ continue
27
+ style = step.get('style', 'all')
28
+ if hasattr(current, 'subset'):
29
+ current = current.subset({column: values}, filter_style=style)
30
+ else:
31
+ matches = current[column].isin([v for v in values if v is not None])
32
+ if any(v is None for v in values):
33
+ matches |= current[column].isna()
34
+ if style in ('negative all', 'negative any'):
35
+ matches = ~matches
36
+ current = current.loc[matches].copy()
37
+ # Derived plot/table frames are often ordinary pandas DataFrames (without
38
+ # Summary.dtfilter). Filter their actual datetime column without assuming
39
+ # that the source column is literally named "datetime".
40
+ time_col = settings.get('time_col')
41
+ if time_col not in current.columns or not pd.api.types.is_datetime64_any_dtype(current[time_col]):
42
+ time_col = next((col for col in current.columns
43
+ if pd.api.types.is_datetime64_any_dtype(current[col])), None)
44
+ if time_col is not None:
45
+ timestamps = pd.to_datetime(current[time_col], errors='coerce')
46
+ mask = pd.Series(True, index=current.index)
47
+ for field, lower in (('datetime_start', True), ('datetime_end', False)):
48
+ kind, value = _parse_datetime_filter(settings.get(field))
49
+ if kind == 'date':
50
+ bound = datetime.combine(value, time.min)
51
+ if lower:
52
+ mask &= timestamps.ge(bound)
53
+ else:
54
+ mask &= timestamps.lt(bound + timedelta(days=1))
55
+ elif kind == 'datetime':
56
+ mask &= timestamps.ge(value) if lower else timestamps.lt(value)
57
+ elif kind == 'time':
58
+ times = timestamps.dt.time
59
+ mask &= times.ge(value) if lower else times.lt(value)
60
+ current = current.loc[mask.fillna(False)].copy()
61
+ for column in settings.get('numeric_subset_columns', []):
62
+ if column not in current.columns or not pd.api.types.is_numeric_dtype(current[column]):
63
+ continue
64
+ values = pd.to_numeric(current[column], errors='coerce')
65
+ for comparison in ('gt', 'lt'):
66
+ raw = settings.get('numeric_subset_bounds', {}).get(column, {}).get(comparison, '')
67
+ try:
68
+ bound = float(str(raw).strip())
69
+ except (ValueError, TypeError):
70
+ continue
71
+ if not np.isfinite(bound):
72
+ continue
73
+ mask = values.gt(bound) if comparison == 'gt' else values.lt(bound)
74
+ current = current.loc[mask.fillna(False) | values.isna()].copy()
75
+ values = values.loc[current.index]
76
+ return current
@@ -0,0 +1,118 @@
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, *, 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'")
72
+ if report.empty:
73
+ return pd.DataFrame()
74
+ key = report.columns[0]
75
+ portion = report.loc[report['variable'].eq(variable)].copy()
76
+ if portion.empty:
77
+ return pd.DataFrame()
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)
90
+
91
+
92
+ def missing_interval_runs(report):
93
+ """Group consecutive zero-observation bins into gap episodes per monitor/variable.
94
+
95
+ A new episode begins whenever an observed bin separates missing bins.
96
+ The report must include empty intervals to expose fully absent time bins.
97
+ """
98
+ fields = ['identifier', 'variable', 'first_missing_interval', 'last_missing_interval',
99
+ 'missing_intervals', 'expected_observations']
100
+ if report.empty:
101
+ return pd.DataFrame(columns=fields)
102
+ key = report.columns[0]
103
+ rows = []
104
+ for (identifier, variable), group in report.groupby([key, 'variable'], sort=False, dropna=False):
105
+ ordered = group.sort_values('interval')
106
+ run = []
107
+ def finish():
108
+ if run:
109
+ rows.append((identifier, variable, run[0]['interval'], run[-1]['interval'],
110
+ len(run), sum(record['expected'] for record in run)))
111
+ for record in ordered.to_dict('records'):
112
+ if record['observed'] == 0:
113
+ run.append(record)
114
+ else:
115
+ finish()
116
+ run = []
117
+ finish()
118
+ return pd.DataFrame(rows, columns=fields)
@@ -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)