apmtools 0.2.57__tar.gz → 0.2.58__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {apmtools-0.2.57 → apmtools-0.2.58}/PKG-INFO +1 -1
- {apmtools-0.2.57 → apmtools-0.2.58}/pyproject.toml +1 -1
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/classes.py +19 -168
- apmtools-0.2.58/src/apmtools/completeness.py +148 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/plot_configs.py +26 -2
- apmtools-0.2.58/src/apmtools/shiny/quality_view.py +96 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/ui.py +220 -4
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/view_controller.py +14 -3
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/workspace_controller.py +12 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/view_schema.py +1 -1
- {apmtools-0.2.57 → apmtools-0.2.58}/.gitignore +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/LICENSE +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/README.md +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/plot_app.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/appearance.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/data.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/derived_views.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/inherited_source.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/legend_identity.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/multi_axis.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/multi_stat_plot.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/multi_y.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/profile_report.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/profiling.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/state.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/summary.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/tab_initialization.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/text_editors.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/transformations.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/view_aggregation.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/shiny/view_history.py +0 -0
- {apmtools-0.2.57 → apmtools-0.2.58}/src/apmtools/units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: apmtools
|
|
3
|
-
Version: 0.2.
|
|
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
|
"""
|
|
@@ -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,96 @@
|
|
|
1
|
+
"""Headless data-quality summaries and heatmap preparation.
|
|
2
|
+
|
|
3
|
+
The dashboard never infers completeness independently of the Stage 4A engine.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from ..completeness import analyse_completeness
|
|
9
|
+
|
|
10
|
+
QUALITY_FREQUENCIES = ('minute', 'hour', 'day', 'week')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def quality_report(data, settings):
|
|
14
|
+
"""Recompute per-identifier, interval and variable coverage from source data."""
|
|
15
|
+
values = list(settings.get('value_cols', []))
|
|
16
|
+
if not values:
|
|
17
|
+
raise ValueError('Select at least one measurement variable')
|
|
18
|
+
freq = settings.get('frequency', 'hour')
|
|
19
|
+
if freq not in QUALITY_FREQUENCIES:
|
|
20
|
+
raise ValueError(f'Unsupported dashboard frequency: {freq}')
|
|
21
|
+
return analyse_completeness(
|
|
22
|
+
data, values, freq,
|
|
23
|
+
id_col=settings.get('id_col', 'identifier'),
|
|
24
|
+
time_col=settings.get('time_col', 'datetime'),
|
|
25
|
+
min_completeness=float(settings.get('min_completeness', 1.0)),
|
|
26
|
+
include_empty_intervals=bool(settings.get('include_empty_intervals', True)),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def quality_summary(report):
|
|
31
|
+
"""Per-monitor and variable coverage, including zero-observation bins."""
|
|
32
|
+
names = ['identifier', 'variable', 'intervals', 'complete_intervals',
|
|
33
|
+
'observed', 'expected', 'missing', 'coverage_percent',
|
|
34
|
+
'complete_percent']
|
|
35
|
+
if report.empty:
|
|
36
|
+
return pd.DataFrame(columns=names)
|
|
37
|
+
key = report.columns[0]
|
|
38
|
+
stats = (report.groupby([key, 'variable'], dropna=False, sort=True)
|
|
39
|
+
.agg(intervals=('is_complete', 'size'),
|
|
40
|
+
complete_intervals=('is_complete', 'sum'),
|
|
41
|
+
observed=('observed', 'sum'),
|
|
42
|
+
expected=('expected', 'sum'),
|
|
43
|
+
missing=('missing', 'sum')).reset_index())
|
|
44
|
+
stats['coverage_percent'] = (100 * stats['observed'] / stats['expected']).where(stats['expected'] > 0)
|
|
45
|
+
stats['complete_percent'] = 100 * stats['complete_intervals'] / stats['intervals']
|
|
46
|
+
return stats.rename(columns={key: 'identifier'})[names]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def selected_heatmap_variable(value_cols, selected):
|
|
50
|
+
"""Return a heatmap variable present in the coverage report.
|
|
51
|
+
|
|
52
|
+
Changing the selected measurements cannot leave the heatmap looking for a
|
|
53
|
+
numeric column that was not included in the report.
|
|
54
|
+
"""
|
|
55
|
+
choices = list(value_cols or [])
|
|
56
|
+
return selected if selected in choices else (choices[0] if choices else None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def quality_heatmap_data(report, variable):
|
|
60
|
+
"""Interval coverage matrix (identifiers x bins); missing data remain NaN."""
|
|
61
|
+
if report.empty:
|
|
62
|
+
return pd.DataFrame()
|
|
63
|
+
key = report.columns[0]
|
|
64
|
+
portion = report.loc[report['variable'].eq(variable), [key, 'interval', 'completeness']].copy()
|
|
65
|
+
if portion.empty:
|
|
66
|
+
return pd.DataFrame()
|
|
67
|
+
return portion.pivot_table(index=key, columns='interval', values='completeness', aggfunc='mean', dropna=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def missing_interval_runs(report):
|
|
71
|
+
"""Group consecutive zero-observation bins into gap episodes per monitor/variable.
|
|
72
|
+
|
|
73
|
+
A new episode begins whenever an observed bin separates missing bins.
|
|
74
|
+
The report must include empty intervals to expose fully absent time bins.
|
|
75
|
+
"""
|
|
76
|
+
fields = ['identifier', 'variable', 'first_missing_interval', 'last_missing_interval',
|
|
77
|
+
'missing_intervals', 'expected_observations']
|
|
78
|
+
if report.empty:
|
|
79
|
+
return pd.DataFrame(columns=fields)
|
|
80
|
+
key = report.columns[0]
|
|
81
|
+
rows = []
|
|
82
|
+
for (identifier, variable), group in report.groupby([key, 'variable'], sort=False, dropna=False):
|
|
83
|
+
ordered = group.sort_values('interval')
|
|
84
|
+
run = []
|
|
85
|
+
def finish():
|
|
86
|
+
if run:
|
|
87
|
+
rows.append((identifier, variable, run[0]['interval'], run[-1]['interval'],
|
|
88
|
+
len(run), sum(record['expected'] for record in run)))
|
|
89
|
+
for record in ordered.to_dict('records'):
|
|
90
|
+
if record['observed'] == 0:
|
|
91
|
+
run.append(record)
|
|
92
|
+
else:
|
|
93
|
+
finish()
|
|
94
|
+
run = []
|
|
95
|
+
finish()
|
|
96
|
+
return pd.DataFrame(rows, columns=fields)
|
|
@@ -15,6 +15,7 @@ from .config import VARIABLE_UNITS, PLOT_TYPES, FREQUENCIES, AGGREGATES, _MPL_PA
|
|
|
15
15
|
from .columns import _alphabetical, _categorical_columns, _datetime_columns, _default_group, _default_x, _normalise_y, _numeric_columns, _timedelta_columns
|
|
16
16
|
from .data import _aggregate, _apply_filters, _composite_labels, _subset_choices
|
|
17
17
|
from .view_aggregation import normalize_statistics
|
|
18
|
+
from .quality_view import quality_report, quality_summary, quality_heatmap_data, missing_interval_runs, QUALITY_FREQUENCIES, selected_heatmap_variable
|
|
18
19
|
from .matplotlib_renderer import _axis_bounds_from_spec, _format_panel_bounds, _matplotlib_defaults, _matplotlib_direct_figure, _pad_axis_bounds, _panel_axis_bounds, _panel_partitions, _panel_trace_defaults
|
|
19
20
|
from .elements import element_keys, filter_elements, default_visible_keys
|
|
20
21
|
from .export import _safe_csv_filename, _standalone_matplotlib_script
|
|
@@ -168,8 +169,8 @@ def _build_summary_ui_server(
|
|
|
168
169
|
"plots and panel titles (Matplotlib backend)."),
|
|
169
170
|
),
|
|
170
171
|
ui.h4("Plot tabs, export and application theme"),
|
|
171
|
-
ui.p("Use
|
|
172
|
-
"
|
|
172
|
+
ui.p("Use New plot, New table, or New quality to open independent views. "
|
|
173
|
+
"Close tab removes the selected view; the Info tab remains available."),
|
|
173
174
|
ui.p("Export plotted data as CSV saves the data displayed in the plot. "
|
|
174
175
|
"Export plot saves the chart. With the Matplotlib backend, "
|
|
175
176
|
"Export as a Python file and associated data creates a "
|
|
@@ -2030,6 +2031,166 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2030
2031
|
return snapshot
|
|
2031
2032
|
|
|
2032
2033
|
|
|
2034
|
+
# Quality dashboards have independent controls and do not modify plot data.
|
|
2035
|
+
@module.ui
|
|
2036
|
+
def quality_tab_ui(settings=None):
|
|
2037
|
+
settings = settings or {}
|
|
2038
|
+
id_choices = _alphabetical([c for c in columns if c not in numeric_columns])
|
|
2039
|
+
times = datetime_columns
|
|
2040
|
+
selected_values = [v for v in settings.get("value_cols", numeric_columns[:1]) if v in numeric_columns]
|
|
2041
|
+
selected_heatmap = selected_heatmap_variable(selected_values, settings.get("heatmap_variable"))
|
|
2042
|
+
return ui.div(
|
|
2043
|
+
ui.layout_sidebar(
|
|
2044
|
+
ui.sidebar(
|
|
2045
|
+
ui.h4("Coverage analysis"),
|
|
2046
|
+
ui.input_select("id_col", "Monitor / identifier column",
|
|
2047
|
+
choices=id_choices,
|
|
2048
|
+
selected=settings.get("id_col", "identifier") if settings.get("id_col", "identifier") in id_choices else (id_choices[0] if id_choices else None)),
|
|
2049
|
+
ui.input_select("time_col", "Timestamp column",
|
|
2050
|
+
choices=times,
|
|
2051
|
+
selected=settings.get("time_col", "datetime") if settings.get("time_col", "datetime") in times else (times[0] if times else None)),
|
|
2052
|
+
ui.input_selectize("value_cols", "Measurements",
|
|
2053
|
+
choices=numeric_columns, multiple=True,
|
|
2054
|
+
selected=selected_values),
|
|
2055
|
+
ui.input_select("frequency", "Aggregation frequency",
|
|
2056
|
+
choices={v: v.capitalize() for v in QUALITY_FREQUENCIES},
|
|
2057
|
+
selected=settings.get("frequency", "hour")),
|
|
2058
|
+
ui.input_numeric("min_completeness", "Minimum completeness (0–1)",
|
|
2059
|
+
value=settings.get("min_completeness", 1.0), min=0, max=1, step=0.05),
|
|
2060
|
+
ui.input_checkbox("include_empty_intervals", "Include entirely missing intervals",
|
|
2061
|
+
value=settings.get("include_empty_intervals", True)),
|
|
2062
|
+
ui.input_select("preview_rows", "Interval table preview",
|
|
2063
|
+
choices={"500": "500 rows", "1000": "1,000 rows", "5000": "5,000 rows", "all": "All rows"},
|
|
2064
|
+
selected="1000"),
|
|
2065
|
+
ui.input_select("heatmap_variable", "Heatmap measurement",
|
|
2066
|
+
choices={v: v for v in selected_values},
|
|
2067
|
+
selected=selected_heatmap),
|
|
2068
|
+
ui.p("Coverage is estimated from each monitor's observed sampling cadence. "
|
|
2069
|
+
"Intervals outside the recording span cannot be inferred.",
|
|
2070
|
+
class_="small text-muted"),
|
|
2071
|
+
ui.download_button("download_quality_csv", "Export interval coverage CSV"),
|
|
2072
|
+
title="Data quality", position="left", open="open", width="320px",
|
|
2073
|
+
),
|
|
2074
|
+
ui.div(
|
|
2075
|
+
ui.output_ui("quality_status"),
|
|
2076
|
+
ui.h4("Coverage by monitor and measurement"),
|
|
2077
|
+
ui.output_data_frame("quality_summary_grid"),
|
|
2078
|
+
ui.h4("Completeness heatmap"),
|
|
2079
|
+
ui.output_plot("quality_heatmap", height="420px"),
|
|
2080
|
+
ui.h4("Entirely missing interval sequences"),
|
|
2081
|
+
ui.output_data_frame("quality_gaps_grid"),
|
|
2082
|
+
ui.h4("Individual intervals"),
|
|
2083
|
+
ui.output_data_frame("quality_intervals_grid"),
|
|
2084
|
+
class_="p-3", style="min-width: 0; width: 100%;",
|
|
2085
|
+
),
|
|
2086
|
+
), class_="summary-quality-tab",
|
|
2087
|
+
)
|
|
2088
|
+
|
|
2089
|
+
@module.server
|
|
2090
|
+
def quality_tab_server(input, output, session, settings=None):
|
|
2091
|
+
settings = settings or {}
|
|
2092
|
+
|
|
2093
|
+
@reactive.calc
|
|
2094
|
+
def report_settings():
|
|
2095
|
+
# Keep heatmap-only edits out of the expensive coverage calculation.
|
|
2096
|
+
return {
|
|
2097
|
+
"id_col": input.id_col(),
|
|
2098
|
+
"time_col": input.time_col(),
|
|
2099
|
+
"value_cols": list(input.value_cols() or []),
|
|
2100
|
+
"frequency": input.frequency(),
|
|
2101
|
+
"min_completeness": float(input.min_completeness() if input.min_completeness() is not None else 1.0),
|
|
2102
|
+
"include_empty_intervals": bool(input.include_empty_intervals()),
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
@reactive.effect
|
|
2106
|
+
@reactive.event(input.value_cols)
|
|
2107
|
+
def sync_heatmap_choices():
|
|
2108
|
+
# Only selected measurement variables appear in the report. Keep
|
|
2109
|
+
# the heatmap selector in sync, but never recreate its input.
|
|
2110
|
+
choices = list(input.value_cols() or [])
|
|
2111
|
+
with reactive.isolate():
|
|
2112
|
+
selected = input.heatmap_variable()
|
|
2113
|
+
ui.update_select("heatmap_variable", choices={v: v for v in choices},
|
|
2114
|
+
selected=selected_heatmap_variable(choices, selected) or "")
|
|
2115
|
+
|
|
2116
|
+
@reactive.calc
|
|
2117
|
+
def quality_settings():
|
|
2118
|
+
conf = report_settings()
|
|
2119
|
+
return {**conf, "heatmap_variable": selected_heatmap_variable(
|
|
2120
|
+
conf["value_cols"], input.heatmap_variable())}
|
|
2121
|
+
|
|
2122
|
+
@reactive.calc
|
|
2123
|
+
def report():
|
|
2124
|
+
conf = report_settings()
|
|
2125
|
+
if not conf["value_cols"] or not conf["id_col"] or not conf["time_col"]:
|
|
2126
|
+
return pd.DataFrame()
|
|
2127
|
+
return quality_report(summary, conf)
|
|
2128
|
+
|
|
2129
|
+
@render.ui
|
|
2130
|
+
def quality_status():
|
|
2131
|
+
try:
|
|
2132
|
+
result = report()
|
|
2133
|
+
if result.empty:
|
|
2134
|
+
return ui.p("Select a measurement to analyse coverage.", class_="text-muted")
|
|
2135
|
+
missing_bins = int((result["observed"] == 0).sum())
|
|
2136
|
+
return ui.p(f"{len(result):,} measurement intervals; "
|
|
2137
|
+
f"{int(result['is_complete'].sum()):,} complete; "
|
|
2138
|
+
f"{missing_bins:,} entirely missing.", class_="text-muted")
|
|
2139
|
+
except (ValueError, KeyError) as exc:
|
|
2140
|
+
return ui.p(str(exc), class_="text-danger")
|
|
2141
|
+
|
|
2142
|
+
@render.data_frame
|
|
2143
|
+
def quality_summary_grid():
|
|
2144
|
+
return render.DataGrid(quality_summary(report()), filters=True, width="100%", height="230px")
|
|
2145
|
+
|
|
2146
|
+
@render.data_frame
|
|
2147
|
+
def quality_gaps_grid():
|
|
2148
|
+
return render.DataGrid(missing_interval_runs(report()), filters=True, width="100%", height="190px")
|
|
2149
|
+
|
|
2150
|
+
@render.data_frame
|
|
2151
|
+
def quality_intervals_grid():
|
|
2152
|
+
data = report()
|
|
2153
|
+
preview = input.preview_rows()
|
|
2154
|
+
if preview != 'all':
|
|
2155
|
+
data = data.head(int(preview))
|
|
2156
|
+
data = data.copy()
|
|
2157
|
+
if 'sampling_interval' in data:
|
|
2158
|
+
data['sampling_interval'] = data['sampling_interval'].astype(str)
|
|
2159
|
+
return render.DataGrid(data, filters=True, width="100%", height="320px")
|
|
2160
|
+
|
|
2161
|
+
@render.plot
|
|
2162
|
+
def quality_heatmap():
|
|
2163
|
+
import matplotlib.pyplot as plt
|
|
2164
|
+
from matplotlib.colors import Normalize
|
|
2165
|
+
conf = quality_settings()
|
|
2166
|
+
matrix = quality_heatmap_data(report(), conf["heatmap_variable"])
|
|
2167
|
+
fig, ax = plt.subplots(figsize=(10, 4))
|
|
2168
|
+
if matrix.empty:
|
|
2169
|
+
ax.text(0.5, 0.5, "No data for selected measurement", ha="center", va="center")
|
|
2170
|
+
ax.set_axis_off()
|
|
2171
|
+
return fig
|
|
2172
|
+
colors = plt.get_cmap("viridis").copy()
|
|
2173
|
+
colors.set_bad("lightgray")
|
|
2174
|
+
image = ax.imshow(matrix.to_numpy(dtype=float), aspect="auto", interpolation="nearest",
|
|
2175
|
+
cmap=colors, norm=Normalize(vmin=0, vmax=1))
|
|
2176
|
+
ax.set_yticks(range(len(matrix.index)))
|
|
2177
|
+
ax.set_yticklabels([str(v) for v in matrix.index], fontsize=8)
|
|
2178
|
+
ticks = sorted(set([0, len(matrix.columns) - 1] + list(range(0, len(matrix.columns), max(1, len(matrix.columns) // 8)))))
|
|
2179
|
+
ax.set_xticks(ticks)
|
|
2180
|
+
ax.set_xticklabels([str(matrix.columns[i]) for i in ticks], rotation=35, ha="right", fontsize=8)
|
|
2181
|
+
ax.set_xlabel("Aggregation interval")
|
|
2182
|
+
ax.set_ylabel(conf["id_col"])
|
|
2183
|
+
ax.set_title(f"{conf['heatmap_variable']} coverage")
|
|
2184
|
+
fig.colorbar(image, ax=ax, label="Observed / expected")
|
|
2185
|
+
fig.tight_layout()
|
|
2186
|
+
return fig
|
|
2187
|
+
|
|
2188
|
+
@render.download(filename="completeness_intervals.csv")
|
|
2189
|
+
def download_quality_csv():
|
|
2190
|
+
yield report().to_csv(index=False)
|
|
2191
|
+
|
|
2192
|
+
return quality_settings
|
|
2193
|
+
|
|
2033
2194
|
@module.ui
|
|
2034
2195
|
def table_tab_ui(frame, settings=None):
|
|
2035
2196
|
settings = settings or {}
|
|
@@ -2517,6 +2678,7 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2517
2678
|
ui.div(
|
|
2518
2679
|
ui.input_action_button("add_tab", "New plot", class_="btn-primary"),
|
|
2519
2680
|
ui.input_action_button("add_table", "New table", class_="btn-primary"),
|
|
2681
|
+
ui.input_action_button("add_quality", "New quality", class_="btn-primary"),
|
|
2520
2682
|
ui.input_action_button("retain_plot", "Save", class_="btn-outline-primary"),
|
|
2521
2683
|
ui.input_action_button("restore_plot", "Load", class_="btn-outline-primary"),
|
|
2522
2684
|
ui.input_action_button("undo_view", "Undo", class_="btn-outline-secondary"),
|
|
@@ -2557,6 +2719,7 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2557
2719
|
table_settings_by_tab = workspace.table_settings
|
|
2558
2720
|
table_source_steps = workspace.table_lineage
|
|
2559
2721
|
next_table = reactive.value(0)
|
|
2722
|
+
next_quality = reactive.value(0)
|
|
2560
2723
|
from ..plot_configs import PlotConfigManager
|
|
2561
2724
|
manager = config_manager or PlotConfigManager(data)
|
|
2562
2725
|
views = ViewController(workspace, manager)
|
|
@@ -2596,6 +2759,22 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2596
2759
|
except (TypeError, ValueError, KeyError):
|
|
2597
2760
|
pass
|
|
2598
2761
|
|
|
2762
|
+
def register_quality_history(tab_id):
|
|
2763
|
+
first = True
|
|
2764
|
+
@reactive.effect
|
|
2765
|
+
def sync_quality_history():
|
|
2766
|
+
nonlocal first
|
|
2767
|
+
if workspace.kind(tab_id) != 'quality':
|
|
2768
|
+
return
|
|
2769
|
+
try:
|
|
2770
|
+
state = dict(workspace.quality_settings[tab_id]())
|
|
2771
|
+
if first:
|
|
2772
|
+
first = False
|
|
2773
|
+
elif views.record_edit(tab_id, state):
|
|
2774
|
+
refresh_history()
|
|
2775
|
+
except (TypeError, ValueError, KeyError):
|
|
2776
|
+
pass
|
|
2777
|
+
|
|
2599
2778
|
@render.ui
|
|
2600
2779
|
def view_edit_status():
|
|
2601
2780
|
history_revision.get()
|
|
@@ -2632,7 +2811,7 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2632
2811
|
@traced('retain_selected', kind='reactive_effect', trigger='@reactive.event(input.retain_plot)')
|
|
2633
2812
|
def retain_selected():
|
|
2634
2813
|
selected = input.plot_tabs()
|
|
2635
|
-
if selected not in tab_filters and selected not in table_frames:
|
|
2814
|
+
if selected not in tab_filters and selected not in table_frames and selected not in workspace.quality_settings:
|
|
2636
2815
|
ui.notification_show("Select a plot or table tab to save", type="warning")
|
|
2637
2816
|
return
|
|
2638
2817
|
ui.modal_show(ui.modal(
|
|
@@ -2827,6 +3006,21 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2827
3006
|
ui.insert_nav_panel("plot_tabs", panel, select=True)
|
|
2828
3007
|
ui.modal_remove()
|
|
2829
3008
|
return
|
|
3009
|
+
if preset.get("type") == "quality":
|
|
3010
|
+
number = next_quality.get() + 1
|
|
3011
|
+
tab_id = f"quality_{number}"
|
|
3012
|
+
settings = views.restore_quality(preset_id)
|
|
3013
|
+
panel = ui.nav_panel(preset.get('name') or f"Quality {number}",
|
|
3014
|
+
quality_tab_ui(tab_id, settings), value=tab_id)
|
|
3015
|
+
snapshot = quality_tab_server(tab_id, settings)
|
|
3016
|
+
workspace.add_quality(tab_id, snapshot)
|
|
3017
|
+
begin_history(tab_id, settings, restored=True)
|
|
3018
|
+
register_quality_history(tab_id)
|
|
3019
|
+
next_quality.set(number)
|
|
3020
|
+
active_tabs.set(workspace.active_tabs)
|
|
3021
|
+
ui.insert_nav_panel("plot_tabs", panel, select=True)
|
|
3022
|
+
ui.modal_remove()
|
|
3023
|
+
return
|
|
2830
3024
|
number = next_tab.get() + 1
|
|
2831
3025
|
tab_id = f"plot_{number}"
|
|
2832
3026
|
initialized = initializer.restored_plot(preset_id, tab_id, number)
|
|
@@ -2860,6 +3054,20 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2860
3054
|
active_tabs.set(workspace.active_tabs)
|
|
2861
3055
|
ui.insert_nav_panel("plot_tabs", new_panel, select=True)
|
|
2862
3056
|
|
|
3057
|
+
@reactive.effect
|
|
3058
|
+
@reactive.event(input.add_quality)
|
|
3059
|
+
def add_quality():
|
|
3060
|
+
number = next_quality.get() + 1
|
|
3061
|
+
tab_id = f"quality_{number}"
|
|
3062
|
+
panel = ui.nav_panel(f"Quality {number}", quality_tab_ui(tab_id), value=tab_id)
|
|
3063
|
+
settings = quality_tab_server(tab_id)
|
|
3064
|
+
workspace.add_quality(tab_id, settings)
|
|
3065
|
+
begin_history(tab_id, {})
|
|
3066
|
+
register_quality_history(tab_id)
|
|
3067
|
+
next_quality.set(number)
|
|
3068
|
+
active_tabs.set(workspace.active_tabs)
|
|
3069
|
+
ui.insert_nav_panel("plot_tabs", panel, select=True)
|
|
3070
|
+
|
|
2863
3071
|
@reactive.effect
|
|
2864
3072
|
@reactive.event(input.add_table)
|
|
2865
3073
|
@traced('add_table', kind='reactive_effect', trigger='@reactive.event(input.add_table)')
|
|
@@ -2895,7 +3103,15 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2895
3103
|
# initialized consistently, rather than patching only inputs
|
|
2896
3104
|
# that happen to be visible. Preserve the same history stack.
|
|
2897
3105
|
kind = workspace.kind(selected)
|
|
2898
|
-
if kind == "
|
|
3106
|
+
if kind == "quality":
|
|
3107
|
+
number = next_quality.get() + 1
|
|
3108
|
+
next_quality.set(number)
|
|
3109
|
+
replacement = f"quality_{number}"
|
|
3110
|
+
panel = ui.nav_panel(f"Quality {number}", quality_tab_ui(replacement, settings), value=replacement)
|
|
3111
|
+
quality_snapshot = quality_tab_server(replacement, settings)
|
|
3112
|
+
workspace.add_quality(replacement, quality_snapshot)
|
|
3113
|
+
register_quality_history(replacement)
|
|
3114
|
+
elif kind == "plot":
|
|
2899
3115
|
number = next_tab.get() + 1
|
|
2900
3116
|
next_tab.set(number)
|
|
2901
3117
|
replacement = f"plot_{number}"
|
|
@@ -42,11 +42,11 @@ class ViewController:
|
|
|
42
42
|
|
|
43
43
|
def saved(self):
|
|
44
44
|
"""Return independently mutable copies of both saved view types."""
|
|
45
|
-
return [p for p in self.manager.saved() if p.get('type', 'plot') in ('plot', 'table')]
|
|
45
|
+
return [p for p in self.manager.saved() if p.get('type', 'plot') in ('plot', 'table', 'quality')]
|
|
46
46
|
|
|
47
47
|
def choices(self):
|
|
48
48
|
"""Stable ID -> human-readable label, independent of the active tab."""
|
|
49
|
-
return {p['id']: f"{'Table'
|
|
49
|
+
return {p['id']: f"{ {'table': 'Table', 'plot': 'Plot', 'quality': 'Quality'}.get(p.get('type', 'plot'), 'Plot')}: {p.get('name', p['id'])}"
|
|
50
50
|
for p in self.saved()}
|
|
51
51
|
|
|
52
52
|
def preset(self, preset_id):
|
|
@@ -72,7 +72,18 @@ class ViewController:
|
|
|
72
72
|
if tab_id in self.history._tabs:
|
|
73
73
|
self.history.mark_saved(tab_id, settings)
|
|
74
74
|
return preset_id
|
|
75
|
-
|
|
75
|
+
if kind == 'quality':
|
|
76
|
+
settings = copy.deepcopy(self.workspace.quality_settings[tab_id]())
|
|
77
|
+
preset_id = self.manager.retain_quality(name, settings)
|
|
78
|
+
if tab_id in self.history._tabs:
|
|
79
|
+
self.history.mark_saved(tab_id, settings)
|
|
80
|
+
return preset_id
|
|
81
|
+
raise ValueError('No active plot, table or quality tab')
|
|
82
|
+
|
|
83
|
+
def restore_quality(self, preset_id):
|
|
84
|
+
if self.preset(preset_id).get('type') != 'quality':
|
|
85
|
+
raise ValueError('Saved configuration is not a quality dashboard')
|
|
86
|
+
return self.manager.restore_quality(preset_id)
|
|
76
87
|
|
|
77
88
|
def delete(self, preset_id):
|
|
78
89
|
"""Delete a retained view without affecting any open tab."""
|
|
@@ -11,6 +11,7 @@ class WorkspaceController:
|
|
|
11
11
|
|
|
12
12
|
def __init__(self):
|
|
13
13
|
self.plot_snapshots = {}
|
|
14
|
+
self.quality_settings = {}
|
|
14
15
|
self.table_frames = {}
|
|
15
16
|
self.table_settings = {}
|
|
16
17
|
self.table_lineage = {}
|
|
@@ -19,6 +20,8 @@ class WorkspaceController:
|
|
|
19
20
|
def kind(self, tab_id):
|
|
20
21
|
if tab_id in self.plot_snapshots:
|
|
21
22
|
return 'plot'
|
|
23
|
+
if tab_id in self.quality_settings:
|
|
24
|
+
return 'quality'
|
|
22
25
|
if tab_id in self.table_frames:
|
|
23
26
|
return 'table'
|
|
24
27
|
return None
|
|
@@ -41,9 +44,18 @@ class WorkspaceController:
|
|
|
41
44
|
self.active_tabs += (tab_id,)
|
|
42
45
|
return self.active_tabs
|
|
43
46
|
|
|
47
|
+
def add_quality(self, tab_id, settings):
|
|
48
|
+
self._check_new(tab_id)
|
|
49
|
+
if not callable(settings):
|
|
50
|
+
raise TypeError('Quality settings must be callable')
|
|
51
|
+
self.quality_settings[tab_id] = settings
|
|
52
|
+
self.active_tabs += (tab_id,)
|
|
53
|
+
return self.active_tabs
|
|
54
|
+
|
|
44
55
|
def remove(self, tab_id):
|
|
45
56
|
if tab_id not in self.active_tabs:
|
|
46
57
|
return False
|
|
58
|
+
self.quality_settings.pop(tab_id, None)
|
|
47
59
|
self.plot_snapshots.pop(tab_id, None)
|
|
48
60
|
self.table_frames.pop(tab_id, None)
|
|
49
61
|
self.table_settings.pop(tab_id, None)
|
|
@@ -38,7 +38,7 @@ def validate_saved_views(views):
|
|
|
38
38
|
for index, item in enumerate(views):
|
|
39
39
|
if not isinstance(item, dict):
|
|
40
40
|
raise ValueError(f'Saved view {index} must be a dictionary')
|
|
41
|
-
if item.get('schema_version') != CURRENT_SCHEMA_VERSION or item.get('type') not in ('plot', 'table'):
|
|
41
|
+
if item.get('schema_version') != CURRENT_SCHEMA_VERSION or item.get('type') not in ('plot', 'table', 'quality'):
|
|
42
42
|
raise ValueError(f'Unsupported saved view configuration at index {index}')
|
|
43
43
|
if not isinstance(item.get('settings'), dict):
|
|
44
44
|
raise ValueError(f'Saved view settings must be a dictionary at index {index}')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|