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.
- {apmtools-0.2.57 → apmtools-0.2.59}/PKG-INFO +1 -1
- {apmtools-0.2.57 → apmtools-0.2.59}/pyproject.toml +1 -1
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/classes.py +19 -168
- apmtools-0.2.59/src/apmtools/completeness.py +148 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/plot_configs.py +26 -2
- apmtools-0.2.59/src/apmtools/shiny/quality_filters.py +76 -0
- apmtools-0.2.59/src/apmtools/shiny/quality_view.py +118 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/tab_initialization.py +20 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/ui.py +357 -4
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/view_controller.py +14 -3
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/workspace_controller.py +12 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/view_schema.py +2 -2
- {apmtools-0.2.57 → apmtools-0.2.59}/.gitignore +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/LICENSE +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/README.md +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/plot_app.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/appearance.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/data.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/derived_views.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/inherited_source.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/legend_identity.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/multi_axis.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/multi_stat_plot.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/multi_y.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/profile_report.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/profiling.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/state.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/summary.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/text_editors.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/transformations.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/view_aggregation.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.59}/src/apmtools/shiny/view_history.py +0 -0
- {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.
|
|
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>
|
|
@@ -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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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)
|