apmtools 0.2.60__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.
Files changed (54) hide show
  1. {apmtools-0.2.60 → apmtools-0.2.61}/PKG-INFO +1 -1
  2. {apmtools-0.2.60 → apmtools-0.2.61}/pyproject.toml +1 -1
  3. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/classes.py +306 -100
  4. apmtools-0.2.61/src/apmtools/dataset_creation.py +305 -0
  5. apmtools-0.2.61/src/apmtools/history_codec.py +158 -0
  6. apmtools-0.2.61/src/apmtools/operation_log.py +58 -0
  7. apmtools-0.2.61/src/apmtools/plot_app.py +920 -0
  8. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/data.py +16 -0
  9. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/ui.py +32 -12
  10. apmtools-0.2.60/src/apmtools/legacy.py +0 -188
  11. apmtools-0.2.60/src/apmtools/plot_app.py +0 -293
  12. {apmtools-0.2.60 → apmtools-0.2.61}/.gitignore +0 -0
  13. {apmtools-0.2.60 → apmtools-0.2.61}/LICENSE +0 -0
  14. {apmtools-0.2.60 → apmtools-0.2.61}/README.md +0 -0
  15. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/__init__.py +0 -0
  16. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/completeness.py +0 -0
  17. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/data_processing.py +0 -0
  18. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/functions.py +0 -0
  19. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/plot_configs.py +0 -0
  20. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/__init__.py +0 -0
  21. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/appearance.py +0 -0
  22. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/bar_axis_autoscale.py +0 -0
  23. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/bar_stacking.py +0 -0
  24. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/color_editor.py +0 -0
  25. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/columns.py +0 -0
  26. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/config.py +0 -0
  27. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/derived_views.py +0 -0
  28. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/elements.py +0 -0
  29. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/export.py +0 -0
  30. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/filter_config.py +0 -0
  31. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/histogram_multi.py +0 -0
  32. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/inherited_source.py +0 -0
  33. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/legend_identity.py +0 -0
  34. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
  35. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/multi_axis.py +0 -0
  36. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/multi_stat_plot.py +0 -0
  37. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/multi_y.py +0 -0
  38. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/panel_activation.py +0 -0
  39. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/profile_report.py +0 -0
  40. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/profiling.py +0 -0
  41. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/quality_filters.py +0 -0
  42. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/quality_source.py +0 -0
  43. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/quality_view.py +0 -0
  44. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/state.py +0 -0
  45. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/summary.py +0 -0
  46. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/tab_initialization.py +0 -0
  47. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/text_editors.py +0 -0
  48. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/transformations.py +0 -0
  49. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/view_aggregation.py +0 -0
  50. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/view_controller.py +0 -0
  51. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/view_history.py +0 -0
  52. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/shiny/workspace_controller.py +0 -0
  53. {apmtools-0.2.60 → apmtools-0.2.61}/src/apmtools/units.py +0 -0
  54. {apmtools-0.2.60 → 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.60
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>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.2.60"
7
+ version = "0.2.61"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -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, utc_timezone_shift=0, interpolate=False, add_m={}):
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
- print(
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
- print(
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
- print(
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
- print(
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=utc_timezone_shift))
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
- print(
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=utc_timezone_shift), interpolate_data=interpolate)
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
- print(
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, utc_timezone_shift=utc_timezone_shift, interpolate=interpolate, add_m=add_m)
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
- print(f"processing UPAS {count+1} out of {len(d)}")
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
- print(f"processing Lascar {count+1} out of {len(d)}")
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
- print(f"processing Lcm {count+1} out of {len(d)}")
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
- print(f"processing Purple {count+1} out of {len(d)}")
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 subset(self, filter_dict={}, filter_style='all'):
1068
- """Return rows matching values specified in ``filter_dict``.
1069
-
1070
- Parameters
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
- print("subset function error: type filter_dict should be dict")
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
- return return_dict
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 type(values) is not list:
1171
+ if not isinstance(values, list):
1104
1172
  values = [values]
1105
- if column in return_dict.columns:
1106
- conditions.append(return_dict[column].isin(values).to_numpy())
1173
+ if column in self.columns:
1174
+ conditions.append(self[column].isin(values).to_numpy())
1107
1175
 
1108
1176
  if not conditions:
1109
- return return_dict
1110
-
1111
- conditions = pd.DataFrame(conditions, dtype=bool)
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
- if filter_style == 'all':
1114
- mask = conditions.all(axis=0).to_numpy()
1115
- elif filter_style == 'any':
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
- return return_dict.loc[mask]
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
- """Filters a file by time or date\n
1150
- Input time as dt.time(hrs,min), and date as dt.date(year,month,day),\n
1151
- and day as [1,2] list of days, with 1 Monday and 7 Sunday,\n
1152
- if selecting a specific date interval that includes time, just specify\n
1153
- that as dt.datetime interval under date_start and date_end"""
1154
- if type(time_start) is str:
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 type(time_end) is str:
1231
+ if isinstance(time_end, str):
1157
1232
  time_end = parse(time_end).time()
1158
- if type(date_start) is str:
1233
+ if isinstance(date_start, str):
1159
1234
  date_start = parse(date_start)
1160
- if type(date_end) is str:
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
- self = self.loc[self["datetime"] >= date_start]
1242
+ mask &= dtcol >= date_start
1165
1243
  if date_end is not None:
1166
- self = self.loc[self["datetime"] < date_end]
1167
- if (time_start is not None) & (time_end is not None):
1168
- if time_start > time_end:
1169
- self = self.loc[(self["datetime"].map(lambda x:x.time()) >= time_start)
1170
- | (self["datetime"].map(lambda x: x.time()) < time_end)]
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
- self = self.loc[(self["datetime"].map(lambda x: x.time()) >= time_start)
1173
- & (self["datetime"].map(lambda x: x.time()) < time_end)]
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
- self = self.loc[[a in day for a in [self["datetime"].map(lambda x: x.date().isoweekday())
1183
- for i in range(len(self["datetime"]))]]]
1184
-
1185
- self.description = self.description + \
1186
- f"\nFiltered by {time_start if time_start != None else ""} till {time_end if time_end != None else ""}, {date_start if date_start != None else ""} till {date_end if date_end != None else ""}, {day if day != None else ""}"
1187
- result = self.reset_index()
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
- out = self.subset({"monitor": [value]})
1451
- out = out.dropna(axis=1,how="all")
1452
- out.views = None
1453
- return out
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"