apmtools 0.2.7__tar.gz → 0.2.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.007
3
+ Version: 0.2.009
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.007"
7
+ version = "0.2.009"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -884,60 +884,57 @@ class Summary(pd.DataFrame):
884
884
  return self
885
885
 
886
886
  def subset(self, filter_dict={}, filter_style='all'):
887
+ """Return rows matching values specified in ``filter_dict``.
888
+
889
+ Parameters
890
+ ----------
891
+ filter_dict : dict
892
+ Mapping of column names to iterables of accepted values.
893
+ filter_style : {'all', 'any', 'negative all', 'negative any'}
894
+ ``all`` keeps rows matching every supplied condition; ``any`` keeps
895
+ rows matching at least one condition. ``negative all`` keeps rows
896
+ that match none of the supplied conditions, and ``negative any``
897
+ keeps rows that do not match any individual condition.
898
+
899
+ Notes
900
+ -----
901
+ Filtering is performed positionally rather than assuming that the
902
+ DataFrame index is ``0..n-1``. This is important because ``Summary``
903
+ objects commonly acquire non-contiguous indices after previous
904
+ filtering operations.
887
905
  """
888
- Return a subset of a Summary, specified in the parameter filter_dict (itself a dictionary) or condition (a function that takes at minimum a value from the dictionary as an input parameter, and return True/False if some condition specified in the function is met. Typically a lambda function of the form lambda x: True if condition else False)
889
- filter_dict is {attrib:["attrib_value_x","attrib_value_y",..]}, where
890
- attrib is an attribute of the elements of dictionary, and attrib_value is a list
891
- of the values of such attrib that the elements of returned dictionary can have
892
- specify filter_style='all' if all conditions should be met to be included in the return dictionary, specify filter_style='any' for including when any condition is met. Default is 'all'.
893
- """
894
- if type(filter_dict) != type(dict()):
906
+
907
+ if not isinstance(filter_dict, dict):
895
908
  print("subset function error: type filter_dict should be dict")
896
909
  return
910
+
897
911
  return_dict = copy.deepcopy(self)
898
912
  a = {}
899
913
 
900
- if filter_style == 'all':
901
- filtering_list = [False]*(len(return_dict))
902
- for i, j in filter_dict.items():
903
- if (i in return_dict.columns):
904
- for z in range(len(return_dict)):
905
- if return_dict.loc[z, i] in j:
906
- filtering_list[z] = True
907
- else:
908
- filtering_list[z] = False
909
- return_dict = return_dict.loc[filtering_list]
914
+ if filter_style not in {'all', 'any', 'negative all', 'negative any'}:
915
+ return return_dict
910
916
 
911
- if filter_style == 'any':
912
- filtering_list = [False]*(len(return_dict))
913
- for i, j in filter_dict.items():
914
- if (i in return_dict.columns):
915
- for z in range(len(return_dict)):
916
- if return_dict.loc[z, i] in j:
917
- filtering_list[z] = True
918
- return_dict = return_dict.loc[filtering_list]
919
-
920
- if filter_style == 'negative all':
921
- filtering_list = [True]*(len(return_dict))
922
- for i, j in filter_dict.items():
923
- if (i in return_dict.columns):
924
- for z in range(len(return_dict)):
925
- if return_dict.loc[z, i] in j:
926
- filtering_list[z] = False
927
- else:
928
- filtering_list[z] = True
929
- return_dict = return_dict.loc[filtering_list]
930
-
931
- if filter_style == 'negative any':
932
- filtering_list = [True]*(len(return_dict))
933
- for i, j in filter_dict.items():
934
- if (i in return_dict.columns):
935
- for z in range(len(return_dict)):
936
- if return_dict.loc[z, i] in j:
937
- filtering_list[z] = False
938
- return_dict = return_dict.loc[filtering_list]
939
-
940
- return return_dict
917
+ # Only conditions referring to existing columns can be evaluated.
918
+ conditions = []
919
+ for column, values in filter_dict.items():
920
+ if column in return_dict.columns:
921
+ conditions.append(return_dict[column].isin(values).to_numpy())
922
+
923
+ if not conditions:
924
+ return return_dict
925
+
926
+ conditions = pd.DataFrame(conditions, dtype=bool)
927
+
928
+ if filter_style == 'all':
929
+ mask = conditions.all(axis=0).to_numpy()
930
+ elif filter_style == 'any':
931
+ mask = conditions.any(axis=0).to_numpy()
932
+ elif filter_style == 'negative all':
933
+ mask = (~conditions).all(axis=0).to_numpy()
934
+ else: # negative any
935
+ mask = (~conditions).any(axis=0).to_numpy()
936
+
937
+ return return_dict.loc[mask]
941
938
 
942
939
  def show(self, number=0, key=None):
943
940
  """
@@ -83,15 +83,22 @@ def _subset_choices(data: pd.DataFrame, column: str) -> tuple[dict[str, str], di
83
83
  return choices, value_map
84
84
 
85
85
 
86
- def _apply_filters(data, subset_column, subset_values, datetime_start, datetime_end):
87
- """Apply Summary.subset() and Summary.dtfilter() in that order."""
86
+ def _apply_filters(data, subset_filters, datetime_start=None, datetime_end=None):
87
+ """Apply multiple ``Summary.subset()`` filters and datetime filtering.
88
+
89
+ Parameters
90
+ ----------
91
+ subset_filters : dict
92
+ Mapping of column names to the original values that should be retained.
93
+ Conditions for different columns are combined with ``filter_style="all"``.
94
+ Values within one column are OR'd by :meth:`Summary.subset`.
95
+ """
88
96
  filtered = data
89
97
 
90
- if subset_column != "None" and subset_values:
91
- choices, value_map = _subset_choices(filtered, subset_column)
92
- values = [value_map[key] for key in subset_values if key in value_map]
93
- if values:
94
- filtered = filtered.subset({subset_column: values})
98
+ if subset_filters:
99
+ # Summary.subset() handles non-contiguous DataFrame indices
100
+ # positionally, so no index manipulation is required here.
101
+ filtered = filtered.subset(subset_filters, filter_style="all")
95
102
 
96
103
  if datetime_start or datetime_end:
97
104
  filtered = filtered.dtfilter(
@@ -182,7 +189,7 @@ def create_summary_app(
182
189
  raise ValueError(
183
190
  f"Unknown plot_type {plot_type!r}. Choose one of: {', '.join(PLOT_TYPES)}")
184
191
 
185
- subset_columns = ["None", *columns]
192
+ subset_columns = columns
186
193
 
187
194
  app_ui = ui.page_sidebar(
188
195
  ui.sidebar(
@@ -214,12 +221,12 @@ def create_summary_app(
214
221
  ),
215
222
  ui.hr(),
216
223
  ui.h5("Subset"),
217
- ui.input_select("subset_column", "Column",
218
- choices=subset_columns, selected="None"),
219
224
  ui.input_selectize(
220
- "subset_values", "Values", choices={}, selected=[], multiple=True,
221
- options={"placeholder": "Select values"},
225
+ "subset_columns", "Columns", choices={c: c for c in subset_columns},
226
+ selected=[], multiple=True,
227
+ options={"placeholder": "Select one or more columns"},
222
228
  ),
229
+ ui.output_ui("subset_filters"),
223
230
  ui.hr(),
224
231
  ui.h5("Datetime filter"),
225
232
  ui.input_text(
@@ -246,29 +253,59 @@ def create_summary_app(
246
253
  )
247
254
 
248
255
  def server(input, output, session):
249
- @reactive.effect
250
- def _update_subset_values():
251
- selected_column = input.subset_column()
252
- if selected_column == "None":
253
- choices = {}
254
- else:
256
+ @render.ui
257
+ def subset_filters():
258
+ """Render one value selector for every selected subset column."""
259
+ selected_columns = list(input.subset_columns())
260
+ if not selected_columns:
261
+ return ui.help_text(
262
+ "Select one or more columns above to choose their values."
263
+ )
264
+
265
+ controls = []
266
+ for i, selected_column in enumerate(selected_columns):
267
+ if selected_column not in column_map:
268
+ continue
255
269
  choices, _ = _subset_choices(
256
270
  summary, column_map[selected_column])
257
- ui.update_selectize("subset_values", choices=choices, selected=[])
271
+ input_id = f"subset_values_{i}"
272
+ controls.append(
273
+ ui.input_selectize(
274
+ input_id,
275
+ f"{selected_column} values",
276
+ choices=choices,
277
+ selected=[],
278
+ multiple=True,
279
+ options={
280
+ "placeholder": f"Select {selected_column} values"},
281
+ )
282
+ )
283
+ return ui.div(*controls)
258
284
 
259
285
  @reactive.calc
260
286
  def filtered_summary():
261
- subset_column = input.subset_column()
262
- subset_values = list(input.subset_values())
287
+ selected_columns = list(input.subset_columns())
288
+ subset_filters = {}
289
+
290
+ for i, selected_column in enumerate(selected_columns):
291
+ if selected_column not in column_map:
292
+ continue
293
+ selected_keys = list(input[f"subset_values_{i}"]())
294
+ if not selected_keys:
295
+ # An explicitly selected column with no selected values
296
+ # should not remove all data; it simply contributes no
297
+ # filtering condition until values are chosen.
298
+ continue
299
+ _, value_map = _subset_choices(
300
+ summary, column_map[selected_column])
301
+ values = [value_map[key]
302
+ for key in selected_keys if key in value_map]
303
+ if values:
304
+ subset_filters[column_map[selected_column]] = values
305
+
263
306
  start = input.datetime_start().strip()
264
307
  end = input.datetime_end().strip()
265
- return _apply_filters(
266
- summary,
267
- column_map[subset_column] if subset_column != "None" else "None",
268
- subset_values,
269
- start,
270
- end,
271
- )
308
+ return _apply_filters(summary, subset_filters, start, end)
272
309
 
273
310
  @reactive.calc
274
311
  def selected_data():
@@ -415,17 +452,72 @@ def create_summary_app(
415
452
  facet_row="variable" if len(y_columns) > 1 else None, barmode="group",
416
453
  )
417
454
  else:
418
- if not group_columns:
419
- fig = px.line(plot_df, x=x_column, y=y_columns)
420
- else:
455
+ # ``identifier`` identifies an individual input monitor. It
456
+ # must therefore be used as Plotly's line-group key even when
457
+ # it is not one of the user-selected colour/group columns.
458
+ # This prevents observations from two different monitors
459
+ # being connected by a line merely because they share the
460
+ # same selected group. The identifier is deliberately NOT
461
+ # used for colour, so monitors in the same selected group
462
+ # retain the same colour.
463
+ identifier_column = (
464
+ column_map["identifier"]
465
+ if "identifier" in column_map
466
+ else None
467
+ )
468
+
469
+ if group_columns:
421
470
  group_column = "__plot_group"
422
- long_df = plot_df[[x_column, group_column, *y_columns]].melt(
423
- id_vars=[x_column, group_column], var_name="variable", value_name="value",
471
+ line_columns = [x_column, group_column, *y_columns]
472
+ if identifier_column is not None:
473
+ line_columns.append(identifier_column)
474
+ long_df = plot_df[line_columns].melt(
475
+ id_vars=[
476
+ x_column,
477
+ group_column,
478
+ *([identifier_column]
479
+ if identifier_column is not None else []),
480
+ ],
481
+ var_name="variable",
482
+ value_name="value",
424
483
  )
425
484
  fig = px.line(
426
- long_df, x=x_column, y="value", color=group_column,
485
+ long_df,
486
+ x=x_column,
487
+ y="value",
488
+ color=group_column,
489
+ line_group=identifier_column if identifier_column is not None else None,
427
490
  facet_row="variable" if len(y_columns) > 1 else None,
428
491
  )
492
+ elif identifier_column is not None:
493
+ # With no explicit grouping, retain the normal Y-variable
494
+ # colouring while using identifier to keep each monitor's
495
+ # time series separate.
496
+ if len(y_columns) == 1:
497
+ fig = px.line(
498
+ plot_df,
499
+ x=x_column,
500
+ y=y_columns[0],
501
+ line_group=identifier_column,
502
+ )
503
+ else:
504
+ line_columns = [
505
+ x_column, identifier_column, *y_columns]
506
+ long_df = plot_df[line_columns].melt(
507
+ id_vars=[x_column, identifier_column],
508
+ var_name="variable",
509
+ value_name="value",
510
+ )
511
+ fig = px.line(
512
+ long_df,
513
+ x=x_column,
514
+ y="value",
515
+ color="variable",
516
+ line_group=identifier_column,
517
+ facet_row="variable",
518
+ )
519
+ else:
520
+ fig = px.line(plot_df, x=x_column, y=y_columns)
429
521
 
430
522
  fig.update_layout(
431
523
  title=input.title(),
File without changes
File without changes
File without changes
File without changes