apmtools 0.2.59__tar.gz → 0.2.61__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.59 → apmtools-0.2.61}/PKG-INFO +1 -1
- {apmtools-0.2.59 → apmtools-0.2.61}/pyproject.toml +1 -1
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/classes.py +306 -100
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/completeness.py +15 -0
- apmtools-0.2.61/src/apmtools/dataset_creation.py +305 -0
- apmtools-0.2.61/src/apmtools/history_codec.py +158 -0
- apmtools-0.2.61/src/apmtools/operation_log.py +58 -0
- apmtools-0.2.61/src/apmtools/plot_app.py +920 -0
- apmtools-0.2.61/src/apmtools/shiny/bar_axis_autoscale.py +37 -0
- apmtools-0.2.61/src/apmtools/shiny/bar_stacking.py +58 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/color_editor.py +7 -1
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/data.py +31 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/export.py +16 -1
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/matplotlib_renderer.py +114 -10
- apmtools-0.2.61/src/apmtools/shiny/panel_activation.py +11 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/profiling.py +29 -0
- apmtools-0.2.61/src/apmtools/shiny/quality_source.py +52 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/quality_view.py +80 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/state.py +5 -2
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/tab_initialization.py +15 -12
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/ui.py +329 -105
- apmtools-0.2.59/src/apmtools/legacy.py +0 -188
- apmtools-0.2.59/src/apmtools/plot_app.py +0 -293
- {apmtools-0.2.59 → apmtools-0.2.61}/.gitignore +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/LICENSE +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/README.md +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/plot_configs.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/appearance.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/derived_views.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/inherited_source.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/legend_identity.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/multi_axis.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/multi_stat_plot.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/multi_y.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/profile_report.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/quality_filters.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/summary.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/text_editors.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/transformations.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/view_aggregation.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/view_controller.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/view_history.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/shiny/workspace_controller.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/units.py +0 -0
- {apmtools-0.2.59 → apmtools-0.2.61}/src/apmtools/view_schema.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.61
|
|
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>
|
|
@@ -7,9 +7,74 @@ from datetime import timedelta
|
|
|
7
7
|
from io import StringIO
|
|
8
8
|
from csv import writer
|
|
9
9
|
from dateutil.parser import parse
|
|
10
|
+
import functools
|
|
11
|
+
import inspect
|
|
12
|
+
import json
|
|
13
|
+
import operator
|
|
14
|
+
from functools import reduce
|
|
10
15
|
|
|
11
16
|
from .units import upas_units, lascar_units, purple_units, lcm_units
|
|
12
17
|
from .completeness import analyse_completeness, complete_interval_mask
|
|
18
|
+
from .history_codec import encode_value, decode_value, load_history, save_history as _save_history_file
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_OPS = {">": operator.gt, ">=": operator.ge, "<": operator.lt,
|
|
22
|
+
"<=": operator.le, "==": operator.eq, "!=": operator.ne}
|
|
23
|
+
|
|
24
|
+
def _evaluate_condition(df, spec):
|
|
25
|
+
"""spec examples:
|
|
26
|
+
{"column": "age", "op": ">=", "value": 18}
|
|
27
|
+
{"column": "group", "op": "isin", "value": ["a", "b"]}
|
|
28
|
+
{"column": "age", "op": "isna"}
|
|
29
|
+
{"and": [spec, spec, ...]} / {"or": [...]} / {"not": spec}
|
|
30
|
+
"""
|
|
31
|
+
if "and" in spec:
|
|
32
|
+
return reduce(operator.and_, (_evaluate_condition(df, s) for s in spec["and"]))
|
|
33
|
+
if "or" in spec:
|
|
34
|
+
return reduce(operator.or_, (_evaluate_condition(df, s) for s in spec["or"]))
|
|
35
|
+
if "not" in spec:
|
|
36
|
+
return ~_evaluate_condition(df, spec["not"])
|
|
37
|
+
col, op = df[spec["column"]], spec["op"]
|
|
38
|
+
if op == "isin":
|
|
39
|
+
return col.isin(spec["value"])
|
|
40
|
+
if op == "isna":
|
|
41
|
+
return col.isna()
|
|
42
|
+
if op == "notna":
|
|
43
|
+
return col.notna()
|
|
44
|
+
return _OPS[op](col, spec["value"])
|
|
45
|
+
|
|
46
|
+
def _as_mask(df, m):
|
|
47
|
+
"""Condition dict, boolean Series or array -> clean boolean Series."""
|
|
48
|
+
if isinstance(m, dict):
|
|
49
|
+
m = _evaluate_condition(df, m)
|
|
50
|
+
return pd.Series(m, index=df.index).fillna(False).astype(bool)
|
|
51
|
+
|
|
52
|
+
def recorded(func):
|
|
53
|
+
"""Run the method, then append the call to the result's `history`.
|
|
54
|
+
Raises TypeError up front if the arguments can't be stored in JSON."""
|
|
55
|
+
sig = inspect.signature(func)
|
|
56
|
+
|
|
57
|
+
@functools.wraps(func)
|
|
58
|
+
def wrapper(self, *args, **kwargs):
|
|
59
|
+
bound = sig.bind(self, *args, **kwargs)
|
|
60
|
+
bound.apply_defaults()
|
|
61
|
+
params = {k: v for k, v in bound.arguments.items() if k != "self"}
|
|
62
|
+
if isinstance(params.get("mapping"), dict): # keep key types
|
|
63
|
+
params["mapping"] = [[k, v] for k, v in params["mapping"].items()]
|
|
64
|
+
try:
|
|
65
|
+
params = encode_value(params)
|
|
66
|
+
except TypeError as e:
|
|
67
|
+
raise TypeError(
|
|
68
|
+
f"{func.__name__}: arguments must be history-serializable "
|
|
69
|
+
f"(no masks, Series or callables). {e}") from None
|
|
70
|
+
|
|
71
|
+
result = func(self, *args, **kwargs)
|
|
72
|
+
result.history = list(getattr(self, "history", None) or []) + [
|
|
73
|
+
{"method": func.__name__, "args": params}]
|
|
74
|
+
return result
|
|
75
|
+
return wrapper
|
|
76
|
+
|
|
77
|
+
###
|
|
13
78
|
|
|
14
79
|
class DictionaryPlus(dict):
|
|
15
80
|
def __init__(self, *args, **kwargs):
|
|
@@ -458,12 +523,21 @@ class Dataset(DictionaryPlus):
|
|
|
458
523
|
f"\nSubset by {filter_dict} on style {filter_style} by condition {condition}"
|
|
459
524
|
return a
|
|
460
525
|
|
|
461
|
-
def scan_folder(self, directory="./", levels=[], level=0, monitor=None, levels_dict=None,
|
|
526
|
+
def scan_folder(self, directory="./", levels=[], level=0, monitor=None, levels_dict=None, utc_timezone=0, interpolate=False, add_m={}, message_callback=None):
|
|
462
527
|
|
|
463
528
|
from .data_processing import upas_processing, lascar_processing, purple_processing, lcm_processing
|
|
464
529
|
|
|
530
|
+
def emit(message):
|
|
531
|
+
# Preserve the original console output; the callback is opt-in.
|
|
532
|
+
print(message)
|
|
533
|
+
if message_callback is not None:
|
|
534
|
+
try:
|
|
535
|
+
message_callback(str(message))
|
|
536
|
+
except Exception:
|
|
537
|
+
pass # A UI listener must not terminate a filesystem scan.
|
|
538
|
+
|
|
465
539
|
if len(set(levels).intersection(set(["identifier", "start", "end", "length", "datetime", "timedelta","pm25", "pm25correctedrh", "pm25correctedrhandgrav", "filterid", "grav_not", "grav","PM2.5","PM10","C02","NO2"]))) > 0:
|
|
466
|
-
|
|
540
|
+
emit(
|
|
467
541
|
f"the following levels names are not allowed: {set(levels).intersection(set(["identifier", "start", "end", "length", "datetime", "timedelta", "pm25", "pm25correctedrh", "pm25correctedrhandgrav", "filterid", "grav_not", "grav","PM2.5","PM10","C02","NO2"]))}. Please choose different level names")
|
|
468
542
|
return
|
|
469
543
|
if levels_dict == None:
|
|
@@ -494,7 +568,7 @@ class Dataset(DictionaryPlus):
|
|
|
494
568
|
pass
|
|
495
569
|
elif os.path.isfile(f"{directory}{j}"):
|
|
496
570
|
if monitor_signature(f"{directory}{j}") == None:
|
|
497
|
-
|
|
571
|
+
emit(
|
|
498
572
|
f"file {directory}{j} is not associated with any monitor type.")
|
|
499
573
|
else:
|
|
500
574
|
monitor = monitor_signature(f"{directory}{j}")
|
|
@@ -512,7 +586,7 @@ class Dataset(DictionaryPlus):
|
|
|
512
586
|
processed.m[key] = value
|
|
513
587
|
self[str(uuid.uuid4())] = processed
|
|
514
588
|
except:
|
|
515
|
-
|
|
589
|
+
emit(
|
|
516
590
|
f"processing of file {directory}{j} failed. Is this a {monitor} file?")
|
|
517
591
|
case "lascar":
|
|
518
592
|
try:
|
|
@@ -527,12 +601,12 @@ class Dataset(DictionaryPlus):
|
|
|
527
601
|
processed.m[key] = value
|
|
528
602
|
self[str(uuid.uuid4())] = processed
|
|
529
603
|
except:
|
|
530
|
-
|
|
604
|
+
emit(
|
|
531
605
|
f"processing of file {directory}{j} failed. Is this a {monitor} file?")
|
|
532
606
|
case "lcm":
|
|
533
607
|
try:
|
|
534
608
|
processed = lcm_processing(
|
|
535
|
-
directory, file=j, interpolate_data=interpolate, timezone_shift=timedelta(hours=
|
|
609
|
+
directory, file=j, interpolate_data=interpolate, timezone_shift=timedelta(hours=utc_timezone))
|
|
536
610
|
for k, v in levels_dict.items():
|
|
537
611
|
if (v not in ["None", "_", "-", None]) and (k not in ["monitor", "", None, "-", "_"]):
|
|
538
612
|
processed.m[k] = v
|
|
@@ -542,12 +616,12 @@ class Dataset(DictionaryPlus):
|
|
|
542
616
|
processed.m[key] = value
|
|
543
617
|
self[str(uuid.uuid4())] = processed
|
|
544
618
|
except:
|
|
545
|
-
|
|
619
|
+
emit(
|
|
546
620
|
f"processing of file {directory}{j} failed. Is this a {monitor} file?")
|
|
547
621
|
case "purple":
|
|
548
622
|
try:
|
|
549
623
|
processed = purple_processing(
|
|
550
|
-
f"{directory}/", timezone_shift=timedelta(hours=
|
|
624
|
+
f"{directory}/", timezone_shift=timedelta(hours=utc_timezone), interpolate_data=interpolate)
|
|
551
625
|
for k, v in levels_dict.items():
|
|
552
626
|
if (v not in ["None", "_", "-", None]) and (k not in ["monitor", "", None, "-", "_"]):
|
|
553
627
|
processed.m[k] = v
|
|
@@ -558,7 +632,7 @@ class Dataset(DictionaryPlus):
|
|
|
558
632
|
self[str(uuid.uuid4())] = processed
|
|
559
633
|
break
|
|
560
634
|
except:
|
|
561
|
-
|
|
635
|
+
emit(
|
|
562
636
|
f"processing of purple air directory {directory}{j} failed.")
|
|
563
637
|
break
|
|
564
638
|
|
|
@@ -573,9 +647,17 @@ class Dataset(DictionaryPlus):
|
|
|
573
647
|
if levels[r] not in levels[:level+1]:
|
|
574
648
|
levels_dict[levels[r]] = None
|
|
575
649
|
self.scan_folder(directory=f"{directory}{j}/", levels=levels,
|
|
576
|
-
level=level+1, monitor=monitor, levels_dict=levels_dict,
|
|
650
|
+
level=level+1, monitor=monitor, levels_dict=levels_dict, utc_timezone=utc_timezone, interpolate=interpolate, add_m=add_m, message_callback=message_callback)
|
|
651
|
+
|
|
652
|
+
def save_summary(self, save_csv=True, filename="./", all_variables=False, merged=True, message_callback=None):
|
|
653
|
+
def emit(message):
|
|
654
|
+
print(message)
|
|
655
|
+
if message_callback is not None:
|
|
656
|
+
try:
|
|
657
|
+
message_callback(str(message))
|
|
658
|
+
except Exception:
|
|
659
|
+
pass # Reporting cannot corrupt Summary construction.
|
|
577
660
|
|
|
578
|
-
def save_summary(self, save_csv=True, filename="./", all_variables=False, merged=True):
|
|
579
661
|
types_in = list(set(type(v) for v in self.values()))
|
|
580
662
|
|
|
581
663
|
def match_class(x):
|
|
@@ -629,7 +711,7 @@ class Dataset(DictionaryPlus):
|
|
|
629
711
|
df.writerow(columns1+columns2+columns3+columns4)
|
|
630
712
|
count = 0
|
|
631
713
|
for k, v in d.items():
|
|
632
|
-
|
|
714
|
+
emit(f"processing UPAS {count+1} out of {len(d)}")
|
|
633
715
|
v = v.dropna(subset="PM2_5MC")
|
|
634
716
|
identifier = k
|
|
635
717
|
start = v.start
|
|
@@ -698,7 +780,7 @@ class Dataset(DictionaryPlus):
|
|
|
698
780
|
df.writerow(columns1+columns2+columns3)
|
|
699
781
|
count = 0
|
|
700
782
|
for k, v in d.items():
|
|
701
|
-
|
|
783
|
+
emit(f"processing Lascar {count+1} out of {len(d)}")
|
|
702
784
|
v = v.dropna(subset="CO(ppm)")
|
|
703
785
|
identifier = k
|
|
704
786
|
start = v.start
|
|
@@ -748,7 +830,7 @@ class Dataset(DictionaryPlus):
|
|
|
748
830
|
df.writerow(columns1+columns2+columns3+columns4)
|
|
749
831
|
count = 0
|
|
750
832
|
for k, v in d.items():
|
|
751
|
-
|
|
833
|
+
emit(f"processing Lcm {count+1} out of {len(d)}")
|
|
752
834
|
v = v.dropna(subset="PM2.5")
|
|
753
835
|
identifier = k
|
|
754
836
|
start = v.start
|
|
@@ -796,7 +878,7 @@ class Dataset(DictionaryPlus):
|
|
|
796
878
|
df.writerow(columns1+columns2+columns3+columns4)
|
|
797
879
|
count = 0
|
|
798
880
|
for k, v in d.items():
|
|
799
|
-
|
|
881
|
+
emit(f"processing Purple {count+1} out of {len(d)}")
|
|
800
882
|
v = v.dropna(subset="pm_adj")
|
|
801
883
|
identifier = k
|
|
802
884
|
start = v.start
|
|
@@ -1012,7 +1094,7 @@ class Group(pd.DataFrame):
|
|
|
1012
1094
|
|
|
1013
1095
|
class Summary(pd.DataFrame):
|
|
1014
1096
|
|
|
1015
|
-
_metadata = ['metadata', 'views', 'variable', 'description']
|
|
1097
|
+
_metadata = ['metadata', 'views', 'variable', 'description', "history"]
|
|
1016
1098
|
|
|
1017
1099
|
@property
|
|
1018
1100
|
def _constructor(self):
|
|
@@ -1025,6 +1107,7 @@ class Summary(pd.DataFrame):
|
|
|
1025
1107
|
self.variable = variable
|
|
1026
1108
|
self.description = description
|
|
1027
1109
|
self.views = None
|
|
1110
|
+
self.history = None
|
|
1028
1111
|
|
|
1029
1112
|
@property
|
|
1030
1113
|
def plots(self):
|
|
@@ -1036,8 +1119,17 @@ class Summary(pd.DataFrame):
|
|
|
1036
1119
|
self.views = value
|
|
1037
1120
|
|
|
1038
1121
|
def __setattr__(self, name, value):
|
|
1122
|
+
"""Assign Summary metadata without pandas' column-creation fallback.
|
|
1123
|
+
|
|
1124
|
+
Older pickles may hold a stale instance-level ``_metadata``. Always
|
|
1125
|
+
consult the class declaration for metadata names; ordinary DataFrame
|
|
1126
|
+
attributes and column-assignment semantics continue through pandas.
|
|
1127
|
+
"""
|
|
1039
1128
|
if name in ("plots", "views") and value is not None and not isinstance(value, list):
|
|
1040
1129
|
raise TypeError("Summary.views must be None or a list")
|
|
1130
|
+
if name in type(self)._metadata or name == "plots":
|
|
1131
|
+
object.__setattr__(self, name, value)
|
|
1132
|
+
return
|
|
1041
1133
|
super().__setattr__(name, value)
|
|
1042
1134
|
|
|
1043
1135
|
def __setstate__(self, state):
|
|
@@ -1051,7 +1143,7 @@ class Summary(pd.DataFrame):
|
|
|
1051
1143
|
if "views" not in self.__dict__:
|
|
1052
1144
|
object.__setattr__(self, "views", legacy)
|
|
1053
1145
|
for name, default in (("views", None), ("variable", None),
|
|
1054
|
-
("description", ""), ("metadata", set())):
|
|
1146
|
+
("description", ""), ("metadata", set()), ("history", None)):
|
|
1055
1147
|
if name not in self.__dict__:
|
|
1056
1148
|
object.__setattr__(self, name, default)
|
|
1057
1149
|
|
|
@@ -1061,67 +1153,48 @@ class Summary(pd.DataFrame):
|
|
|
1061
1153
|
self.metadata = getattr(other, "metadata", set()).copy()
|
|
1062
1154
|
self.description = getattr(other, "description", "")
|
|
1063
1155
|
self.views = getattr(other, "views", getattr(other, "plots", None))
|
|
1156
|
+
self.history = copy.deepcopy(getattr(other, "history", None))
|
|
1064
1157
|
|
|
1065
1158
|
return self
|
|
1066
1159
|
|
|
1067
|
-
def
|
|
1068
|
-
"""
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
----------
|
|
1072
|
-
filter_dict : dict
|
|
1073
|
-
Mapping of column names to iterables of accepted values.
|
|
1074
|
-
filter_style : {'all', 'any', 'negative all', 'negative any'}
|
|
1075
|
-
``all`` keeps rows matching every supplied condition; ``any`` keeps
|
|
1076
|
-
rows matching at least one condition. ``negative all`` keeps rows
|
|
1077
|
-
that match none of the supplied conditions, and ``negative any``
|
|
1078
|
-
keeps rows that do not match any individual condition.
|
|
1079
|
-
|
|
1080
|
-
Notes
|
|
1081
|
-
-----
|
|
1082
|
-
Filtering is performed positionally rather than assuming that the
|
|
1083
|
-
DataFrame index is ``0..n-1``. This is important because ``Summary``
|
|
1084
|
-
objects commonly acquire non-contiguous indices after previous
|
|
1085
|
-
filtering operations.
|
|
1086
|
-
"""
|
|
1087
|
-
|
|
1160
|
+
def _subset_impl(self, filter_dict=None, filter_style='all'):
|
|
1161
|
+
"""Apply categorical filters without recording an internal history step."""
|
|
1162
|
+
if filter_dict is None:
|
|
1163
|
+
filter_dict = {}
|
|
1088
1164
|
if not isinstance(filter_dict, dict):
|
|
1089
|
-
|
|
1090
|
-
return
|
|
1091
|
-
|
|
1092
|
-
return_dict = copy.deepcopy(self)
|
|
1093
|
-
return_dict.views = None
|
|
1094
|
-
a = {}
|
|
1095
|
-
|
|
1165
|
+
raise TypeError("subset: filter_dict must be a dictionary")
|
|
1096
1166
|
if filter_style not in {'all', 'any', 'negative all', 'negative any'}:
|
|
1097
|
-
|
|
1167
|
+
raise ValueError(f"subset: invalid filter_style {filter_style!r}")
|
|
1098
1168
|
|
|
1099
|
-
|
|
1100
|
-
# Only conditions referring to existing columns can be evaluated.
|
|
1101
1169
|
conditions = []
|
|
1102
1170
|
for column, values in filter_dict.items():
|
|
1103
|
-
if
|
|
1171
|
+
if not isinstance(values, list):
|
|
1104
1172
|
values = [values]
|
|
1105
|
-
if column in
|
|
1106
|
-
conditions.append(
|
|
1173
|
+
if column in self.columns:
|
|
1174
|
+
conditions.append(self[column].isin(values).to_numpy())
|
|
1107
1175
|
|
|
1108
1176
|
if not conditions:
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1177
|
+
result = self.copy(deep=False)
|
|
1178
|
+
else:
|
|
1179
|
+
conditions = pd.DataFrame(conditions, dtype=bool)
|
|
1180
|
+
if filter_style == 'all':
|
|
1181
|
+
mask = conditions.all(axis=0).to_numpy()
|
|
1182
|
+
elif filter_style == 'any':
|
|
1183
|
+
mask = conditions.any(axis=0).to_numpy()
|
|
1184
|
+
elif filter_style == 'negative all':
|
|
1185
|
+
mask = (~conditions).all(axis=0).to_numpy()
|
|
1186
|
+
else: # negative any
|
|
1187
|
+
mask = (~conditions).any(axis=0).to_numpy()
|
|
1188
|
+
result = self.loc[mask].copy()
|
|
1112
1189
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
mask = conditions.any(axis=0).to_numpy()
|
|
1117
|
-
elif filter_style == 'negative all':
|
|
1118
|
-
mask = (~conditions).all(axis=0).to_numpy()
|
|
1119
|
-
else: # negative any
|
|
1120
|
-
mask = (~conditions).any(axis=0).to_numpy()
|
|
1121
|
-
|
|
1122
|
-
return_dict.description = self.description + f"\nSubset by {filter_dict} on style {filter_style}"
|
|
1190
|
+
result.views = None
|
|
1191
|
+
result.description = self.description + f"\nSubset by {filter_dict} on style {filter_style}"
|
|
1192
|
+
return result
|
|
1123
1193
|
|
|
1124
|
-
|
|
1194
|
+
@recorded
|
|
1195
|
+
def subset(self, filter_dict=None, filter_style='all'):
|
|
1196
|
+
"""Return a new Summary matching categorical filters, recording one step."""
|
|
1197
|
+
return self._subset_impl(filter_dict, filter_style)
|
|
1125
1198
|
|
|
1126
1199
|
def show(self, number=0, key=None):
|
|
1127
1200
|
"""
|
|
@@ -1139,6 +1212,7 @@ class Summary(pd.DataFrame):
|
|
|
1139
1212
|
except:
|
|
1140
1213
|
print("something's wrong")
|
|
1141
1214
|
|
|
1215
|
+
@recorded
|
|
1142
1216
|
def dtfilter(
|
|
1143
1217
|
self,
|
|
1144
1218
|
time_start=None,
|
|
@@ -1146,45 +1220,51 @@ class Summary(pd.DataFrame):
|
|
|
1146
1220
|
date_start=None,
|
|
1147
1221
|
date_end=None,
|
|
1148
1222
|
day=None):
|
|
1149
|
-
"""
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1223
|
+
"""Filter datetime values, recording one reproducible history step.
|
|
1224
|
+
|
|
1225
|
+
Date intervals are start-inclusive and end-exclusive. With both time
|
|
1226
|
+
endpoints supplied, an overnight interval crosses midnight. Weekdays
|
|
1227
|
+
are ISO values 1 (Monday) through 7 (Sunday).
|
|
1228
|
+
"""
|
|
1229
|
+
if isinstance(time_start, str):
|
|
1155
1230
|
time_start = parse(time_start).time()
|
|
1156
|
-
if
|
|
1231
|
+
if isinstance(time_end, str):
|
|
1157
1232
|
time_end = parse(time_end).time()
|
|
1158
|
-
if
|
|
1233
|
+
if isinstance(date_start, str):
|
|
1159
1234
|
date_start = parse(date_start)
|
|
1160
|
-
if
|
|
1235
|
+
if isinstance(date_end, str):
|
|
1161
1236
|
date_end = parse(date_end)
|
|
1162
1237
|
|
|
1238
|
+
# Always start with a fresh mask so even no-op calls return a new object.
|
|
1239
|
+
mask = pd.Series(True, index=self.index)
|
|
1240
|
+
dtcol = self["datetime"]
|
|
1163
1241
|
if date_start is not None:
|
|
1164
|
-
|
|
1242
|
+
mask &= dtcol >= date_start
|
|
1165
1243
|
if date_end is not None:
|
|
1166
|
-
|
|
1167
|
-
if
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1244
|
+
mask &= dtcol < date_end
|
|
1245
|
+
if time_start is not None or time_end is not None:
|
|
1246
|
+
times = pd.to_datetime(dtcol).dt.time
|
|
1247
|
+
if time_start is not None and time_end is not None:
|
|
1248
|
+
if time_start > time_end:
|
|
1249
|
+
mask &= ((times >= time_start) | (times < time_end))
|
|
1250
|
+
else:
|
|
1251
|
+
mask &= ((times >= time_start) & (times < time_end))
|
|
1252
|
+
elif time_start is not None:
|
|
1253
|
+
mask &= times >= time_start
|
|
1171
1254
|
else:
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
if (time_start is not None) & (time_end is None):
|
|
1175
|
-
self = self.loc[self["datetime"].map(
|
|
1176
|
-
lambda x: x.time()) >= time_start]
|
|
1177
|
-
if (time_start is None) & (time_end is not None):
|
|
1178
|
-
self = self.loc[self["datetime"].map(
|
|
1179
|
-
lambda x: x.time()) <= time_end]
|
|
1180
|
-
|
|
1255
|
+
# Preserve the existing end-only inclusive convention.
|
|
1256
|
+
mask &= times < time_end
|
|
1181
1257
|
if day is not None:
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1258
|
+
weekdays = pd.to_datetime(dtcol).dt.isocalendar().day
|
|
1259
|
+
mask &= weekdays.isin(day if isinstance(day, (list, tuple, set)) else [day])
|
|
1260
|
+
|
|
1261
|
+
result = self.loc[mask.fillna(False).to_numpy()].copy().reset_index()
|
|
1262
|
+
result.description = (self.description +
|
|
1263
|
+
f"\nFiltered by {time_start if time_start is not None else ''} till "
|
|
1264
|
+
f"{time_end if time_end is not None else ''}, "
|
|
1265
|
+
f"{date_start if date_start is not None else ''} till "
|
|
1266
|
+
f"{date_end if date_end is not None else ''}, "
|
|
1267
|
+
f"{day if day is not None else ''}")
|
|
1188
1268
|
result.views = None
|
|
1189
1269
|
return result
|
|
1190
1270
|
|
|
@@ -1445,13 +1525,139 @@ class Summary(pd.DataFrame):
|
|
|
1445
1525
|
|
|
1446
1526
|
return output
|
|
1447
1527
|
|
|
1528
|
+
@recorded
|
|
1448
1529
|
def get_monitor(self, value):
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
return
|
|
1530
|
+
"""Select a monitor without separately recording the internal subset."""
|
|
1531
|
+
result = self._subset_impl({"monitor": [value]}, 'all')
|
|
1532
|
+
result = result.dropna(axis=1, how="all")
|
|
1533
|
+
result.views = None
|
|
1534
|
+
return result
|
|
1535
|
+
|
|
1536
|
+
@recorded
|
|
1537
|
+
def change_values(self, column, mapping):
|
|
1538
|
+
if not isinstance(mapping, dict):
|
|
1539
|
+
mapping = {k: v for k, v in mapping}
|
|
1540
|
+
result = self.copy()
|
|
1541
|
+
datatype = result[column].dtype
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def map_value(x):
|
|
1545
|
+
try:
|
|
1546
|
+
return mapping.get(x, x)
|
|
1547
|
+
except TypeError: # x is unhashable, so it can't be a key in mapping
|
|
1548
|
+
return x
|
|
1549
|
+
|
|
1550
|
+
result[column] = result[column].map(map_value)
|
|
1551
|
+
|
|
1552
|
+
try:
|
|
1553
|
+
result[column] = result[column].astype(datatype)
|
|
1554
|
+
except (ValueError, TypeError):
|
|
1555
|
+
pass
|
|
1556
|
+
|
|
1557
|
+
result.views = None
|
|
1558
|
+
return result
|
|
1559
|
+
|
|
1560
|
+
@recorded
|
|
1561
|
+
def map_values(self, target_column, mask, when_true, when_false=None, subset=None):
|
|
1562
|
+
"""mask: condition dict deciding True/False, e.g.
|
|
1563
|
+
{"column": "age", "op": ">=", "value": 18}
|
|
1564
|
+
when_false: if None, rows where the mask is False keep their values.
|
|
1565
|
+
subset: optional condition dict; only rows satisfying it are eligible
|
|
1566
|
+
for change. Rows outside it always keep their original values.
|
|
1567
|
+
Mask and subset are both evaluated on the data as it was on entry.
|
|
1568
|
+
"""
|
|
1569
|
+
result = self.copy()
|
|
1570
|
+
original = result[target_column]
|
|
1571
|
+
datatype = original.dtype
|
|
1572
|
+
|
|
1573
|
+
mask = _as_mask(result, mask)
|
|
1574
|
+
scope = (_as_mask(result, subset) if subset is not None
|
|
1575
|
+
else pd.Series(True, index=result.index))
|
|
1576
|
+
|
|
1577
|
+
if when_false is None:
|
|
1578
|
+
new_values = original.where(~(mask & scope), when_true)
|
|
1579
|
+
else:
|
|
1580
|
+
new_values = pd.Series(
|
|
1581
|
+
np.where(mask, when_true, when_false), index=result.index
|
|
1582
|
+
).where(scope, original)
|
|
1583
|
+
|
|
1584
|
+
result[target_column] = new_values
|
|
1585
|
+
try:
|
|
1586
|
+
result[target_column] = result[target_column].astype(datatype)
|
|
1587
|
+
except (ValueError, TypeError):
|
|
1588
|
+
pass
|
|
1589
|
+
|
|
1590
|
+
result.views = None
|
|
1591
|
+
return result
|
|
1592
|
+
|
|
1593
|
+
@recorded
|
|
1594
|
+
def edit_columns(self, action, column, new_name=None, fill=None):
|
|
1595
|
+
"""Return a modified copy of the DataFrame; the original is never changed.
|
|
1596
|
+
|
|
1597
|
+
action: "add" | "rename" | "remove" | "duplicate"
|
|
1598
|
+
column: add -> name of the new column
|
|
1599
|
+
rename -> existing column to rename
|
|
1600
|
+
remove -> existing column to drop
|
|
1601
|
+
duplicate -> existing column to copy
|
|
1602
|
+
new_name: required for "rename" and "duplicate"
|
|
1603
|
+
|
|
1604
|
+
Invalid name situations fail quietly: the unchanged copy is returned.
|
|
1605
|
+
"""
|
|
1606
|
+
result = self.copy()
|
|
1607
|
+
cols = result.columns
|
|
1608
|
+
|
|
1609
|
+
if action == "add":
|
|
1610
|
+
if column in cols:
|
|
1611
|
+
return result
|
|
1612
|
+
result[column] = fill # appended at the end
|
|
1613
|
+
|
|
1614
|
+
elif action == "rename":
|
|
1615
|
+
if column not in cols or new_name is None or new_name in cols:
|
|
1616
|
+
return result
|
|
1617
|
+
result = result.rename(columns={column: new_name})
|
|
1618
|
+
|
|
1619
|
+
elif action == "remove":
|
|
1620
|
+
if column not in cols:
|
|
1621
|
+
return result
|
|
1622
|
+
result = result.drop(columns=[column])
|
|
1454
1623
|
|
|
1624
|
+
elif action == "duplicate":
|
|
1625
|
+
if column not in cols or new_name is None or new_name in cols:
|
|
1626
|
+
return result
|
|
1627
|
+
result[new_name] = result[column].copy() # appended at the end
|
|
1628
|
+
|
|
1629
|
+
else:
|
|
1630
|
+
return result
|
|
1631
|
+
|
|
1632
|
+
result.views = None
|
|
1633
|
+
return result
|
|
1634
|
+
|
|
1635
|
+
# ---- serialization -------------------------------------------------
|
|
1636
|
+
def save_history(self, path):
|
|
1637
|
+
"""Save transformation history as strict, versioned JSON."""
|
|
1638
|
+
_save_history_file(path, self.history or [])
|
|
1639
|
+
|
|
1640
|
+
def apply_history(self, source):
|
|
1641
|
+
"""Replay approved recorded transformations from a path or step list.
|
|
1642
|
+
|
|
1643
|
+
History describes explicitly recorded methods. Arbitrary pandas
|
|
1644
|
+
slicing and other non-recorded operations are not captured.
|
|
1645
|
+
"""
|
|
1646
|
+
steps = load_history(source) # Validate *all* steps before execution.
|
|
1647
|
+
validated = []
|
|
1648
|
+
for index, step in enumerate(steps):
|
|
1649
|
+
method = getattr(self, step["method"])
|
|
1650
|
+
kwargs = decode_value(step["args"])
|
|
1651
|
+
try:
|
|
1652
|
+
inspect.signature(method).bind(**kwargs)
|
|
1653
|
+
except TypeError as exc:
|
|
1654
|
+
raise ValueError(f"Invalid arguments for history step {index}: {exc}") from exc
|
|
1655
|
+
validated.append((step["method"], kwargs))
|
|
1656
|
+
|
|
1657
|
+
result = self
|
|
1658
|
+
for method_name, kwargs in validated:
|
|
1659
|
+
result = getattr(result, method_name)(**kwargs)
|
|
1660
|
+
return result
|
|
1455
1661
|
|
|
1456
1662
|
class Apm(pd.DataFrame):
|
|
1457
1663
|
monitor = "apm"
|
|
@@ -82,10 +82,25 @@ def analyse_completeness(data, value_cols, frequency, *, time_col='datetime',
|
|
|
82
82
|
values = list(value_cols)
|
|
83
83
|
if not values:
|
|
84
84
|
raise ValueError('value_cols must not be empty')
|
|
85
|
+
# Each column must have one unambiguous role. In particular, selecting
|
|
86
|
+
# the timestamp as the monitor ID makes pandas return a two-column frame
|
|
87
|
+
# for frame[time_col], which pd.to_datetime cannot convert.
|
|
85
88
|
required = [id_col, time_col, *values]
|
|
89
|
+
if len(set(required)) != len(required):
|
|
90
|
+
raise ValueError(
|
|
91
|
+
'Identifier, timestamp and measurement columns must be distinct; '
|
|
92
|
+
'choose different columns in Coverage analysis.'
|
|
93
|
+
)
|
|
86
94
|
absent = [name for name in required if name not in data.columns]
|
|
87
95
|
if absent:
|
|
88
96
|
raise KeyError(f'Missing completeness columns: {absent}')
|
|
97
|
+
# Avoid silently choosing one of several identically named source columns.
|
|
98
|
+
duplicates = [name for name in required if list(data.columns).count(name) > 1]
|
|
99
|
+
if duplicates:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f'Duplicate source column names {list(dict.fromkeys(duplicates))!r}; '
|
|
102
|
+
'rename them before calculating completeness.'
|
|
103
|
+
)
|
|
89
104
|
frame = pd.DataFrame(data)[required].copy()
|
|
90
105
|
frame[time_col] = pd.to_datetime(frame[time_col], errors='coerce')
|
|
91
106
|
frame = frame.dropna(subset=[id_col, time_col])
|