apmtools 0.2.13__tar.gz → 0.2.15__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.013
3
+ Version: 0.2.015
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.013"
7
+ version = "0.2.015"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -39,9 +39,9 @@ class DictionaryPlus(dict):
39
39
  """
40
40
  Return a subset of a DictionaryPlus, 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)
41
41
  filter_dict is {attrib:["attrib_value_x","attrib_value_y",..]}, where
42
- attrib is an attribute of the elements of dictionary, and attrib_value is a list
42
+ attrib is an attribute of the elements of dictionary, and attrib_value is a value or list
43
43
  of the values of such attrib that the elements of returned dictionary can have
44
- 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'.
44
+ specify filter_style='all' if all conditions should be met to be included in the return dictionary, other filters allowed are 'any', 'negative all', 'negative any'.
45
45
  """
46
46
  if type(filter_dict) != type(dict()):
47
47
  print("subset function error: type filter_dict should be dict")
@@ -49,14 +49,17 @@ class DictionaryPlus(dict):
49
49
  return_dict = copy.deepcopy(self)
50
50
  a = {}
51
51
 
52
+ if filter_style not in {'all', 'any', 'negative all', 'negative any'}:
53
+ return return_dict
54
+
52
55
  if filter_style == 'all':
53
56
  a = {key: value for key, value in return_dict.items()}
54
57
  for key, value in return_dict.items():
55
58
  for i, j in filter_dict.items():
56
59
  if hasattr(value, i):
57
60
  try:
58
- if type(j) == type(""):
59
- if not eval("value.__getattr__(\""+i+"\")" + j):
61
+ if type(j) is not list:
62
+ if getattr(value, i) not in [j]:
60
63
  del a[key]
61
64
  break
62
65
  else:
@@ -67,8 +70,8 @@ class DictionaryPlus(dict):
67
70
  pass
68
71
  elif hasattr(value, 'm') & (type(value.m) == type({})) & (i in value.m.keys()):
69
72
  try:
70
- if type(j) == type(""):
71
- if not eval("value.__getattr__('m')[\""+i+"\"]" + j):
73
+ if type(j) is not list:
74
+ if getattr(value, 'm')[i] not in [j]:
72
75
  del a[key]
73
76
  break
74
77
  else:
@@ -86,8 +89,8 @@ class DictionaryPlus(dict):
86
89
  for i, j in filter_dict.items():
87
90
  if hasattr(value, 'm') & (type(value.m) == type({})) & (i in value.m.keys()):
88
91
  try:
89
- if type(j) == type(""):
90
- if eval("value.__getattr__('m')[\""+i+"\"]" + j):
92
+ if type(j) is not list:
93
+ if getattr(value, 'm')[i] in [j]:
91
94
  a[key] = value
92
95
  break
93
96
  else:
@@ -98,8 +101,8 @@ class DictionaryPlus(dict):
98
101
  pass
99
102
  else:
100
103
  try:
101
- if type(j) == type(""):
102
- if eval("value.__getattr__(\""+i+"\")" + j):
104
+ if type(j) is not list:
105
+ if getattr(value, i) in [j]:
103
106
  a[key] = value
104
107
  break
105
108
  else:
@@ -109,14 +112,14 @@ class DictionaryPlus(dict):
109
112
  except:
110
113
  pass
111
114
 
112
- if filter_style == 'negative':
115
+ if filter_style == 'negative all':
113
116
  a = {key: value for key, value in return_dict.items()}
114
117
  for key, value in return_dict.items():
115
118
  for i, j in filter_dict.items():
116
119
  if hasattr(value, i):
117
120
  try:
118
- if type(j) == type(""):
119
- if eval("value.__getattr__(\""+i+"\")" + j):
121
+ if type(j) is not list:
122
+ if getattr(value, i) in [j]:
120
123
  del a[key]
121
124
  break
122
125
  else:
@@ -127,8 +130,8 @@ class DictionaryPlus(dict):
127
130
  pass
128
131
  elif hasattr(value, 'm') & (type(value.m) == type({})) & (i in value.m.keys()):
129
132
  try:
130
- if type(j) == type(""):
131
- if eval("value.__getattr__('m')[\""+i+"\"]" + j):
133
+ if type(j) is not list:
134
+ if getattr(value, 'm')[i] in [j]:
132
135
  del a[key]
133
136
  break
134
137
  else:
@@ -140,6 +143,40 @@ class DictionaryPlus(dict):
140
143
  else:
141
144
  break
142
145
 
146
+ if filter_style == 'negative any':
147
+ b = {key: value for key, value in return_dict.items()}
148
+ for key, value in return_dict.items():
149
+ for i, j in filter_dict.items():
150
+ if hasattr(value, i):
151
+ try:
152
+ if type(j) is not list:
153
+ if getattr(value, i) not in [j]:
154
+ del b[key]
155
+ break
156
+ else:
157
+ if getattr(value, i) not in j:
158
+ del b[key]
159
+ break
160
+ except:
161
+ pass
162
+ elif hasattr(value, 'm') & (type(value.m) == type({})) & (i in value.m.keys()):
163
+ try:
164
+ if type(j) is not list:
165
+ if getattr(value, 'm')[i] not in [j]:
166
+ del b[key]
167
+ break
168
+ else:
169
+ if getattr(value, 'm')[i] not in j:
170
+ del b[key]
171
+ break
172
+ except:
173
+ pass
174
+ else:
175
+ del b[key]
176
+ break
177
+ a = {key: value for key, value in return_dict.items()
178
+ if key not in b.keys()}
179
+
143
180
  if condition != None:
144
181
  if a == {}:
145
182
  for key, value in return_dict.items():
@@ -264,14 +301,17 @@ class Dataset(DictionaryPlus):
264
301
  return_dict = copy.deepcopy(self)
265
302
  a = {}
266
303
 
304
+ if filter_style not in {'all', 'any', 'negative all', 'negative any'}:
305
+ return return_dict
306
+
267
307
  if filter_style == 'all':
268
308
  a = {key: value for key, value in return_dict.items()}
269
309
  for key, value in return_dict.items():
270
310
  for i, j in filter_dict.items():
271
311
  if hasattr(value, i):
272
312
  try:
273
- if type(j) == type(""):
274
- if not eval("value.__getattr__(\""+i+"\")" + j):
313
+ if type(j) is not list:
314
+ if getattr(value, i) not in [j]:
275
315
  del a[key]
276
316
  break
277
317
  else:
@@ -282,8 +322,8 @@ class Dataset(DictionaryPlus):
282
322
  pass
283
323
  elif hasattr(value, 'm') & (type(value.m) == type({})) & (i in value.m.keys()):
284
324
  try:
285
- if type(j) == type(""):
286
- if not eval("value.__getattr__('m')[\""+i+"\"]" + j):
325
+ if type(j) is not list:
326
+ if getattr(value, 'm')[i] not in [j]:
287
327
  del a[key]
288
328
  break
289
329
  else:
@@ -301,8 +341,8 @@ class Dataset(DictionaryPlus):
301
341
  for i, j in filter_dict.items():
302
342
  if hasattr(value, 'm') & (type(value.m) == type({})) & (i in value.m.keys()):
303
343
  try:
304
- if type(j) == type(""):
305
- if eval("value.__getattr__('m')[\""+i+"\"]" + j):
344
+ if type(j) is not list:
345
+ if getattr(value, 'm')[i] in [j]:
306
346
  a[key] = value
307
347
  break
308
348
  else:
@@ -313,8 +353,8 @@ class Dataset(DictionaryPlus):
313
353
  pass
314
354
  else:
315
355
  try:
316
- if type(j) == type(""):
317
- if eval("value.__getattr__(\""+i+"\")" + j):
356
+ if type(j) is not list:
357
+ if getattr(value, i) in [j]:
318
358
  a[key] = value
319
359
  break
320
360
  else:
@@ -324,14 +364,14 @@ class Dataset(DictionaryPlus):
324
364
  except:
325
365
  pass
326
366
 
327
- if filter_style == 'negative':
367
+ if filter_style == 'negative all':
328
368
  a = {key: value for key, value in return_dict.items()}
329
369
  for key, value in return_dict.items():
330
370
  for i, j in filter_dict.items():
331
371
  if hasattr(value, i):
332
372
  try:
333
- if type(j) == type(""):
334
- if eval("value.__getattr__(\""+i+"\")" + j):
373
+ if type(j) is not list:
374
+ if getattr(value, i) in [j]:
335
375
  del a[key]
336
376
  break
337
377
  else:
@@ -342,8 +382,8 @@ class Dataset(DictionaryPlus):
342
382
  pass
343
383
  elif hasattr(value, 'm') & (type(value.m) == type({})) & (i in value.m.keys()):
344
384
  try:
345
- if type(j) == type(""):
346
- if eval("value.__getattr__('m')[\""+i+"\"]" + j):
385
+ if type(j) is not list:
386
+ if getattr(value, 'm')[i] in [j]:
347
387
  del a[key]
348
388
  break
349
389
  else:
@@ -355,6 +395,39 @@ class Dataset(DictionaryPlus):
355
395
  else:
356
396
  break
357
397
 
398
+ if filter_style == 'negative any':
399
+ b = {key: value for key, value in return_dict.items()}
400
+ for key, value in return_dict.items():
401
+ for i, j in filter_dict.items():
402
+ if hasattr(value, i):
403
+ try:
404
+ if type(j) is not list:
405
+ if getattr(value, i) not in [j]:
406
+ del b[key]
407
+ break
408
+ else:
409
+ if getattr(value, i) not in j:
410
+ del b[key]
411
+ break
412
+ except:
413
+ pass
414
+ elif hasattr(value, 'm') & (type(value.m) == type({})) & (i in value.m.keys()):
415
+ try:
416
+ if type(j) is not list:
417
+ if getattr(value, 'm')[i] not in [j]:
418
+ del b[key]
419
+ break
420
+ else:
421
+ if getattr(value, 'm')[i] not in j:
422
+ del b[key]
423
+ break
424
+ except:
425
+ pass
426
+ else:
427
+ del b[key]
428
+ break
429
+ a = {key:value for key,value in return_dict.items() if key not in b.keys()}
430
+
358
431
  if condition != None:
359
432
  if a == {}:
360
433
  for key, value in return_dict.items():
@@ -703,7 +776,6 @@ class Dataset(DictionaryPlus):
703
776
  return pd.concat([out[k] for k in out.keys()]).reset_index()
704
777
  return out
705
778
 
706
-
707
779
  def save_data(self, directory="./saved/", levels=[]):
708
780
  def get_specific_level(v, j):
709
781
  if hasattr(v, j):
@@ -919,6 +991,8 @@ class Summary(pd.DataFrame):
919
991
  # Only conditions referring to existing columns can be evaluated.
920
992
  conditions = []
921
993
  for column, values in filter_dict.items():
994
+ if type(values) is not list:
995
+ values = [values]
922
996
  if column in return_dict.columns:
923
997
  conditions.append(return_dict[column].isin(values).to_numpy())
924
998
 
@@ -44,7 +44,10 @@ def _default_x(data: pd.DataFrame) -> str:
44
44
  for column in data.columns
45
45
  if pd.api.types.is_datetime64_any_dtype(data[column])
46
46
  ]
47
- return str(datetime_columns[0] if datetime_columns else data.columns[0])
47
+ if "time" in datetime_columns:
48
+ return "time"
49
+ else:
50
+ return str(datetime_columns[0] if datetime_columns else data.columns[0])
48
51
 
49
52
  def _default_group(data: pd.DataFrame) -> str | None:
50
53
  """Return ``identifier`` when available."""
@@ -58,7 +61,6 @@ def _numeric_columns(data: pd.DataFrame) -> list[str]:
58
61
  if pd.api.types.is_numeric_dtype(data[column])
59
62
  ]
60
63
 
61
-
62
64
  def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> list[str]:
63
65
  """Validate and normalise an initial Y selection."""
64
66
  numeric = _numeric_columns(data)
@@ -71,6 +73,25 @@ def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> l
71
73
  return [str(column) for column in values]
72
74
 
73
75
 
76
+ def _categorical_columns(data: pd.DataFrame) -> list[str]:
77
+ """Return columns suitable for a boxplot categorical X axis."""
78
+ return sorted(
79
+ [
80
+ str(column)
81
+ for column in data.columns
82
+ if not pd.api.types.is_numeric_dtype(data[column])
83
+ and not pd.api.types.is_datetime64_any_dtype(data[column])
84
+ ],
85
+ key=str.casefold,
86
+ )
87
+
88
+
89
+ def _alphabetical(values) -> list[str]:
90
+ """Return string values sorted alphabetically, case-insensitively."""
91
+ return sorted((str(value) for value in values), key=str.casefold)
92
+
93
+
94
+
74
95
  def _subset_choices(data: pd.DataFrame, column: str) -> tuple[dict[str, str], dict[str, object]]:
75
96
  """Build stable Shiny choices and their corresponding original values."""
76
97
  values = pd.unique(data[column].dropna())
@@ -250,9 +271,10 @@ def create_summary_app(
250
271
  raise ValueError(
251
272
  "Cannot create a Shiny application from an empty Summary.")
252
273
 
253
- columns = [str(c) for c in summary.columns]
274
+ columns = _alphabetical(summary.columns)
254
275
  column_map = {str(c): c for c in summary.columns}
255
- numeric_columns = _numeric_columns(summary)
276
+ numeric_columns = _alphabetical(_numeric_columns(summary))
277
+ categorical_columns = _categorical_columns(summary)
256
278
 
257
279
  x = _default_x(summary) if x is None else str(x)
258
280
  if x not in column_map:
@@ -281,12 +303,8 @@ def create_summary_app(
281
303
  ui.h4("Plot controls"),
282
304
  ui.input_select("plot_type", "Plot type",
283
305
  choices=PLOT_TYPES, selected=plot_type),
284
- ui.input_select("x", "X-axis / category",
285
- choices=columns, selected=x),
286
- ui.input_selectize(
287
- "y", "Y-axis / variable", choices=numeric_columns,
288
- selected=initial_y, multiple=True,
289
- ),
306
+ ui.output_ui("x_control"),
307
+ ui.output_ui("y_control"),
290
308
  ui.input_selectize(
291
309
  "group_by", "Group by",
292
310
  choices={c: c for c in columns},
@@ -338,6 +356,43 @@ def create_summary_app(
338
356
  )
339
357
 
340
358
  def server(input, output, session):
359
+ @render.ui
360
+ def x_control():
361
+ """Use categorical X choices for boxplots; all columns otherwise."""
362
+ if input.plot_type() == "box":
363
+ if not categorical_columns:
364
+ return ui.help_text(
365
+ "Boxplots require at least one categorical column for the X axis."
366
+ )
367
+ selected = x if x in categorical_columns else categorical_columns[0]
368
+ return ui.input_select(
369
+ "x", "X-axis / category",
370
+ choices={c: c for c in categorical_columns},
371
+ selected=selected,
372
+ )
373
+ return ui.input_select(
374
+ "x", "X-axis / category",
375
+ choices={c: c for c in columns},
376
+ selected=x,
377
+ )
378
+
379
+ @render.ui
380
+ def y_control():
381
+ """Boxplots accept one numeric Y variable; other plots accept many."""
382
+ if input.plot_type() == "box":
383
+ selected = initial_y[0] if initial_y else (
384
+ numeric_columns[0] if numeric_columns else None)
385
+ return ui.input_select(
386
+ "y", "Y-axis / variable",
387
+ choices={c: c for c in numeric_columns},
388
+ selected=selected,
389
+ )
390
+ return ui.input_selectize(
391
+ "y", "Y-axis / variable",
392
+ choices={c: c for c in numeric_columns},
393
+ selected=initial_y, multiple=True,
394
+ )
395
+
341
396
  @render.ui
342
397
  def subset_filters():
343
398
  """Render one value selector for every selected subset column."""
@@ -395,7 +450,9 @@ def create_summary_app(
395
450
  @reactive.calc
396
451
  def selected_data():
397
452
  current = filtered_summary()
398
- selected_y = list(input.y())
453
+ y_input = input.y()
454
+ selected_y = ([y_input] if isinstance(
455
+ y_input, str) else list(y_input or []))
399
456
  if not selected_y:
400
457
  return pd.DataFrame()
401
458
 
@@ -405,9 +462,18 @@ def create_summary_app(
405
462
  frequency = FREQUENCIES[input.frequency()]
406
463
  aggregate = AGGREGATES[input.aggregate()]
407
464
 
465
+ # A boxplot's categorical X must survive group_stat(). Include it
466
+ # as a statistical grouping key when frequency aggregation is used,
467
+ # without turning it into a colour/group selection in the UI.
468
+ if input.plot_type() == "box":
469
+ selected_x = input.x()
470
+ if selected_x in column_map:
471
+ box_x = column_map[selected_x]
472
+ if box_x not in grouping_by:
473
+ grouping_by = [*grouping_by, box_x]
474
+
408
475
  # All selected grouping columns are passed directly to
409
- # Summary.group_stat() when a frequency is selected. With raw
410
- # data they remain plotting-only grouping columns.
476
+ # Summary.group_stat() when a frequency is selected.
411
477
  if frequency is not None:
412
478
  current = _aggregate(current, y_columns,
413
479
  frequency, grouping_by, aggregate)
@@ -419,7 +485,9 @@ def create_summary_app(
419
485
  plot_df = selected_data()
420
486
  selected_plot = input.plot_type()
421
487
  selected_x = input.x()
422
- selected_y = list(input.y())
488
+ y_input = input.y()
489
+ selected_y = ([y_input] if isinstance(
490
+ y_input, str) else list(y_input or []))
423
491
  selected_groups = list(input.group_by())
424
492
  frequency = FREQUENCIES[input.frequency()]
425
493
 
@@ -441,7 +509,12 @@ def create_summary_app(
441
509
  y_columns = _aggregated_value_columns(
442
510
  plot_df, selected_y_columns, aggregate
443
511
  )
444
- x_column = frequency
512
+ if selected_plot == "box":
513
+ if selected_x not in column_map:
514
+ return px.scatter(title="Select a categorical X variable for the boxplot")
515
+ x_column = column_map[selected_x]
516
+ else:
517
+ x_column = frequency
445
518
 
446
519
  if x_column not in plot_df.columns:
447
520
  return px.scatter(
@@ -511,18 +584,23 @@ def create_summary_app(
511
584
  opacity=0.65 if len(y_columns) > 1 else 1.0,
512
585
  )
513
586
  elif selected_plot == "box":
514
- if not group_columns:
515
- long_df = plot_df[y_columns].melt(
516
- var_name="variable", value_name="value")
517
- fig = px.box(long_df, x="variable",
518
- y="value", points="outliers")
587
+ # Boxplots are distributions of one numerical variable across
588
+ # categories. X is therefore always the selected categorical
589
+ # column and Y is exactly one numerical variable.
590
+ if x_column not in categorical_columns:
591
+ return px.scatter(
592
+ title="Boxplots require a categorical variable on the X axis"
593
+ )
594
+ y_column = y_columns[0]
595
+ if group_columns:
596
+ fig = px.box(
597
+ plot_df, x=x_column, y=y_column,
598
+ color="__plot_group", points="outliers",
599
+ )
519
600
  else:
520
- group_column = "__plot_group"
521
- long_df = plot_df[[group_column, *y_columns]].melt(
522
- id_vars=[group_column], var_name="variable", value_name="value",
601
+ fig = px.box(
602
+ plot_df, x=x_column, y=y_column, points="outliers"
523
603
  )
524
- fig = px.box(long_df, x="variable", y="value",
525
- color=group_column, points="outliers")
526
604
  elif selected_plot == "bar":
527
605
  if not group_columns:
528
606
  fig = px.bar(plot_df, x=x_column,
File without changes
File without changes
File without changes