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.
- {apmtools-0.2.56 → apmtools-0.2.58}/PKG-INFO +1 -1
- {apmtools-0.2.56 → apmtools-0.2.58}/pyproject.toml +1 -1
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/classes.py +38 -200
- apmtools-0.2.58/src/apmtools/completeness.py +148 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/plot_configs.py +31 -26
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/data.py +7 -7
- apmtools-0.2.58/src/apmtools/shiny/derived_views.py +35 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/inherited_source.py +29 -9
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/matplotlib_renderer.py +13 -8
- apmtools-0.2.58/src/apmtools/shiny/multi_stat_plot.py +53 -0
- apmtools-0.2.58/src/apmtools/shiny/profile_report.py +51 -0
- apmtools-0.2.58/src/apmtools/shiny/profiling.py +103 -0
- apmtools-0.2.58/src/apmtools/shiny/quality_view.py +96 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/state.py +14 -0
- apmtools-0.2.58/src/apmtools/shiny/tab_initialization.py +74 -0
- apmtools-0.2.58/src/apmtools/shiny/text_editors.py +23 -0
- apmtools-0.2.58/src/apmtools/shiny/transformations.py +208 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/ui.py +704 -233
- apmtools-0.2.58/src/apmtools/shiny/view_aggregation.py +124 -0
- apmtools-0.2.58/src/apmtools/shiny/view_controller.py +102 -0
- apmtools-0.2.58/src/apmtools/shiny/view_history.py +106 -0
- apmtools-0.2.58/src/apmtools/shiny/workspace_controller.py +70 -0
- apmtools-0.2.58/src/apmtools/view_schema.py +106 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/.gitignore +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/LICENSE +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/README.md +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/plot_app.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/appearance.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/legend_identity.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/multi_axis.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/multi_y.py +0 -0
- {apmtools-0.2.56 → apmtools-0.2.58}/src/apmtools/shiny/summary.py +0 -0
- {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.
|
|
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>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
if
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
frequency
|
|
144
|
-
|
|
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
|