apmtools 0.2.16__tar.gz → 0.2.18__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.016
3
+ Version: 0.2.018
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.016"
7
+ version = "0.2.018"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -9,10 +9,11 @@ import pandas as pd
9
9
 
10
10
 
11
11
  PLOT_TYPES = {
12
- "line": "Line",
13
12
  "bar": "Bar",
14
- "histogram": "Histogram",
15
13
  "box": "Boxplot",
14
+ "histogram": "Histogram",
15
+ "line": "Line",
16
+ "scatter": "Scatter",
16
17
  }
17
18
 
18
19
  # ``None`` means row-level plotting. The other values are passed directly to
@@ -28,13 +29,13 @@ FREQUENCIES = {
28
29
  }
29
30
 
30
31
  AGGREGATES = {
32
+ "Count": "count",
33
+ "Maximum": "max",
31
34
  "Mean": "mean",
32
35
  "Median": "median",
33
- "Sum": "sum",
34
36
  "Minimum": "min",
35
- "Maximum": "max",
36
- "Count": "count",
37
37
  "Standard deviation": "std",
38
+ "Sum": "sum",
38
39
  }
39
40
 
40
41
  def _default_x(data: pd.DataFrame) -> str:
@@ -98,6 +99,7 @@ def _timedelta_columns(data: pd.DataFrame) -> list[str]:
98
99
  key=str.casefold,
99
100
  )
100
101
 
102
+
101
103
  def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> list[str]:
102
104
  """Validate and normalise an initial Y selection."""
103
105
  numeric = _numeric_columns(data)
@@ -177,16 +179,6 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate):
177
179
  return result
178
180
 
179
181
 
180
- def _aggregated_value_columns(data, value_columns, aggregate):
181
- """Return the columns produced by Summary.group_stat()."""
182
- columns = []
183
- for column in value_columns:
184
- aggregated = f"{column}_{aggregate}"
185
- if aggregated in data.columns:
186
- columns.append(aggregated)
187
- return columns
188
-
189
-
190
182
  def _frequency_timedelta(frequency: str) -> pd.Timedelta | None:
191
183
  """Convert a timestamp aggregation frequency to its expected spacing."""
192
184
  if not frequency or frequency == "weekday":
@@ -299,6 +291,10 @@ def create_summary_app(
299
291
  column_map = {str(c): c for c in summary.columns}
300
292
  numeric_columns = _alphabetical(_numeric_columns(summary))
301
293
  categorical_columns = _categorical_columns(summary)
294
+ datetime_columns = _datetime_columns(summary)
295
+ group_columns = _alphabetical(
296
+ c for c in columns if not pd.api.types.is_numeric_dtype(summary[column_map[c]])
297
+ )
302
298
 
303
299
  x = _default_x(summary) if x is None else str(x)
304
300
  if x not in column_map:
@@ -315,6 +311,8 @@ def create_summary_app(
315
311
  unknown_groups = [c for c in initial_groups if c not in column_map]
316
312
  if unknown_groups:
317
313
  raise ValueError(f"Unknown group_by column(s): {unknown_groups!r}")
314
+ # Numeric columns are not offered by the Group by selector.
315
+ initial_groups = [c for c in initial_groups if c in group_columns]
318
316
 
319
317
  if plot_type not in PLOT_TYPES:
320
318
  raise ValueError(
@@ -331,7 +329,7 @@ def create_summary_app(
331
329
  ui.output_ui("y_control"),
332
330
  ui.input_selectize(
333
331
  "group_by", "Group by",
334
- choices={c: c for c in columns},
332
+ choices={c: c for c in group_columns},
335
333
  selected=initial_groups,
336
334
  multiple=True,
337
335
  options={"placeholder": "Select one or more grouping columns"},
@@ -364,8 +362,11 @@ def create_summary_app(
364
362
  "datetime_end", "End datetime",
365
363
  value="", placeholder="YYYY-MM-DD or YYYY-MM-DD HH:MM:SS",
366
364
  ),
367
- ui.input_numeric("bins", "Histogram bins",
368
- value=30, min=1, max=200, step=1),
365
+ ui.panel_conditional(
366
+ "input.plot_type === 'histogram'",
367
+ ui.input_numeric("bins", "Histogram bins",
368
+ value=30, min=1, max=200, step=1),
369
+ ),
369
370
  ui.input_text("title", "Plot title", value=title),
370
371
  ui.help_text(
371
372
  "Subset and datetime filters are applied before aggregation. "
@@ -382,39 +383,53 @@ def create_summary_app(
382
383
  def server(input, output, session):
383
384
  @render.ui
384
385
  def x_control():
385
- """Use categorical X choices for boxplots; all columns otherwise."""
386
- if input.plot_type() == "box":
387
- if not categorical_columns:
388
- return ui.help_text(
389
- "Boxplots require at least one categorical column for the X axis."
390
- )
391
- selected = x if x in categorical_columns else categorical_columns[0]
392
- return ui.input_select(
393
- "x", "X-axis / category",
394
- choices={c: c for c in categorical_columns},
395
- selected=selected,
386
+ kind = input.plot_type()
387
+ if kind in ("box", "bar"):
388
+ choices = categorical_columns
389
+ multiple = True
390
+ elif kind == "line":
391
+ choices = datetime_columns
392
+ multiple = False
393
+ else:
394
+ choices = numeric_columns
395
+ multiple = False
396
+
397
+ if not choices:
398
+ return ui.help_text(f"No compatible X variables for {kind}.")
399
+ selected = [x] if x in choices else [choices[0]]
400
+ if multiple:
401
+ return ui.input_selectize(
402
+ "x", "X-axis / categories",
403
+ choices={c: c for c in choices},
404
+ selected=selected, multiple=True,
396
405
  )
397
406
  return ui.input_select(
398
- "x", "X-axis / category",
399
- choices={c: c for c in columns},
400
- selected=x,
407
+ "x", "X-axis / variable",
408
+ choices={c: c for c in choices},
409
+ selected=selected[0],
401
410
  )
402
411
 
403
412
  @render.ui
404
413
  def y_control():
405
- """Boxplots accept one numeric Y variable; other plots accept many."""
406
- if input.plot_type() == "box":
407
- selected = initial_y[0] if initial_y else (
408
- numeric_columns[0] if numeric_columns else None)
414
+ kind = input.plot_type()
415
+ if kind == "histogram":
416
+ return ui.div()
417
+ if not numeric_columns:
418
+ return ui.help_text("No numeric Y variables available.")
419
+ selected = next(
420
+ (v for v in initial_y if v in numeric_columns), numeric_columns[0])
421
+ if kind in ("box", "bar", "scatter"):
409
422
  return ui.input_select(
410
423
  "y", "Y-axis / variable",
411
424
  choices={c: c for c in numeric_columns},
412
425
  selected=selected,
413
426
  )
414
427
  return ui.input_selectize(
415
- "y", "Y-axis / variable",
428
+ "y", "Y-axis / variables",
416
429
  choices={c: c for c in numeric_columns},
417
- selected=initial_y, multiple=True,
430
+ selected=[v for v in initial_y if v in numeric_columns] or [
431
+ selected],
432
+ multiple=True,
418
433
  )
419
434
 
420
435
  @render.ui
@@ -474,89 +489,92 @@ def create_summary_app(
474
489
  @reactive.calc
475
490
  def selected_data():
476
491
  current = filtered_summary()
477
- y_input = input.y()
478
- selected_y = ([y_input] if isinstance(
479
- y_input, str) else list(y_input or []))
480
- if not selected_y:
492
+ kind = input.plot_type()
493
+ x_input = input.x()
494
+ selected_x = ([x_input] if isinstance(
495
+ x_input, str) else list(x_input or []))
496
+ if not selected_x:
497
+ return pd.DataFrame()
498
+
499
+ if kind == "histogram":
500
+ value_names = selected_x
501
+ else:
502
+ y_input = input.y()
503
+ value_names = ([y_input] if isinstance(
504
+ y_input, str) else list(y_input or []))
505
+ if not value_names:
481
506
  return pd.DataFrame()
482
507
 
483
- y_columns = [column_map[c] for c in selected_y]
484
508
  selected_groups = list(input.group_by())
485
509
  grouping_by = [column_map[c] for c in selected_groups]
486
510
  frequency = FREQUENCIES[input.frequency()]
487
511
  aggregate = AGGREGATES[input.aggregate()]
488
-
489
- # A boxplot's categorical X must survive group_stat(). Include it
490
- # as a statistical grouping key when frequency aggregation is used,
491
- # without turning it into a colour/group selection in the UI.
492
- if input.plot_type() == "box":
493
- selected_x = input.x()
494
- if selected_x in column_map:
495
- box_x = column_map[selected_x]
496
- if box_x not in grouping_by:
497
- grouping_by = [*grouping_by, box_x]
498
-
499
- # All selected grouping columns are passed directly to
500
- # Summary.group_stat() when a frequency is selected.
512
+ value_columns = [column_map[c] for c in value_names]
513
+
514
+ if kind in ("box", "bar"):
515
+ for c in selected_x:
516
+ if column_map[c] not in grouping_by:
517
+ grouping_by.append(column_map[c])
518
+ if kind == "scatter" and frequency is not None:
519
+ # Both numerical axes must be aggregated on the same rows.
520
+ value_columns = list(dict.fromkeys(
521
+ [column_map[selected_x[0]], *value_columns]
522
+ ))
501
523
  if frequency is not None:
502
- current = _aggregate(current, y_columns,
503
- frequency, grouping_by, aggregate)
504
-
524
+ current = _aggregate(
525
+ current, value_columns, frequency, grouping_by, aggregate
526
+ )
505
527
  return current
506
528
 
507
529
  @render_plotly
508
530
  def plot():
509
531
  plot_df = selected_data()
510
532
  selected_plot = input.plot_type()
511
- selected_x = input.x()
512
- y_input = input.y()
533
+ x_input = input.x()
534
+ selected_x = ([x_input] if isinstance(
535
+ x_input, str) else list(x_input or []))
536
+ y_input = None if selected_plot == "histogram" else input.y()
513
537
  selected_y = ([y_input] if isinstance(
514
538
  y_input, str) else list(y_input or []))
515
539
  selected_groups = list(input.group_by())
516
540
  frequency = FREQUENCIES[input.frequency()]
517
541
 
518
- if plot_df.empty or not selected_y:
542
+ if plot_df.empty or not selected_x or (
543
+ selected_plot != "histogram" and not selected_y
544
+ ):
519
545
  return px.scatter(title="No data for the selected filters")
520
546
 
521
- # Keep the original selected Y columns separate from the columns
522
- # produced by group_stat(). For example, selecting ``pm25`` and
523
- # hourly means produces ``pm25_mean`` in the aggregated result.
524
- selected_y_columns = [column_map[c] for c in selected_y]
547
+ aggregate = AGGREGATES[input.aggregate()]
525
548
 
526
- # Summary.group_stat() creates the frequency column itself.
527
- # For example, frequency="hour" creates an ``hour`` column,
528
- # while an aggregate such as mean creates ``pm25_mean``.
529
- # Therefore the aggregated frequency column replaces the
530
- # originally selected X-axis column.
531
- if frequency is not None:
532
- aggregate = AGGREGATES[input.aggregate()]
533
- y_columns = _aggregated_value_columns(
534
- plot_df, selected_y_columns, aggregate
535
- )
536
- if selected_plot == "box":
537
- if selected_x not in column_map:
538
- return px.scatter(title="Select a categorical X variable for the boxplot")
539
- x_column = column_map[selected_x]
540
- else:
541
- x_column = frequency
549
+ def mapped(column):
550
+ original = column_map[column]
551
+ return f"{original}_{aggregate}" if frequency is not None else original
542
552
 
543
- if x_column not in plot_df.columns:
544
- return px.scatter(
545
- title=(
546
- f"Aggregation did not produce the expected "
547
- f"'{x_column}' column"
548
- )
549
- )
553
+ if selected_plot == "histogram":
554
+ x_column = mapped(selected_x[0])
555
+ y_columns = []
556
+ elif selected_plot in ("box", "bar"):
557
+ x_columns = [column_map[c] for c in selected_x]
558
+ x_column = x_columns[0]
559
+ y_columns = [mapped(selected_y[0])]
560
+ elif selected_plot == "scatter":
561
+ x_column = mapped(selected_x[0])
562
+ y_columns = [mapped(selected_y[0])]
550
563
  else:
551
- y_columns = [
552
- c for c in selected_y_columns if c in plot_df.columns
553
- ]
554
- x_column = column_map[selected_x]
555
-
556
- if not y_columns:
557
- return px.scatter(
558
- title="Selected variables are not available after aggregation"
559
- )
564
+ x_column = frequency if frequency is not None else column_map[selected_x[0]]
565
+ y_columns = [mapped(c) for c in selected_y]
566
+
567
+ required = [x_column, *y_columns]
568
+ if selected_plot in ("box", "bar"):
569
+ required.extend(x_columns)
570
+ if any(c not in plot_df.columns for c in required):
571
+ return px.scatter(title="Selected variables are unavailable after aggregation")
572
+
573
+ if selected_plot in ("box", "bar"):
574
+ plot_df = plot_df.copy()
575
+ plot_df["__plot_category"] = plot_df[x_columns].astype(
576
+ str).agg(" | ".join, axis=1)
577
+ x_column = "__plot_category"
560
578
 
561
579
  # Multiple grouping columns are supported by group_stat(). For
562
580
  # plotting, Plotly needs one color/category column, so combine the
@@ -570,74 +588,31 @@ def create_summary_app(
570
588
  )
571
589
 
572
590
  if selected_plot == "histogram":
573
- if group_columns:
574
- # With grouping, overlay one histogram per group on the
575
- # same axes. ``group`` is the color dimension rather
576
- # than the selected variable, so each group gets its own
577
- # histogram. Transparency makes overlapping groups
578
- # directly comparable.
579
- group_column = "__plot_group"
580
- long_df = plot_df[[group_column, *y_columns]].melt(
581
- id_vars=[group_column],
582
- var_name="variable",
583
- value_name="value",
584
- )
585
- fig = px.histogram(
586
- long_df,
587
- x="value",
588
- color=group_column,
589
- nbins=int(input.bins()),
590
- barmode="overlay",
591
- opacity=0.45,
592
- facet_row=("variable" if len(y_columns) > 1 else None),
593
- )
594
- # Keep the groups as the legend entries rather than the
595
- # selected Y variables.
596
- fig.update_traces(marker_line_width=0)
597
- else:
598
- long_df = plot_df[y_columns].melt(
599
- var_name="variable", value_name="value"
600
- )
601
- fig = px.histogram(
602
- long_df,
603
- x="value",
604
- color="variable",
605
- nbins=int(input.bins()),
606
- barmode="overlay" if len(
607
- y_columns) > 1 else "relative",
608
- opacity=0.65 if len(y_columns) > 1 else 1.0,
609
- )
591
+ fig = px.histogram(
592
+ plot_df,
593
+ x=x_column,
594
+ color="__plot_group" if group_columns else None,
595
+ nbins=int(input.bins()),
596
+ barmode="overlay",
597
+ opacity=0.45 if group_columns else 1.0,
598
+ )
610
599
  elif selected_plot == "box":
611
- # Boxplots are distributions of one numerical variable across
612
- # categories. X is therefore always the selected categorical
613
- # column and Y is exactly one numerical variable.
614
- if x_column not in categorical_columns:
615
- return px.scatter(
616
- title="Boxplots require a categorical variable on the X axis"
617
- )
618
- y_column = y_columns[0]
619
- if group_columns:
620
- fig = px.box(
621
- plot_df, x=x_column, y=y_column,
622
- color="__plot_group", points="outliers",
623
- )
624
- else:
625
- fig = px.box(
626
- plot_df, x=x_column, y=y_column, points="outliers"
627
- )
600
+ fig = px.box(
601
+ plot_df, x=x_column, y=y_columns[0],
602
+ color="__plot_group" if group_columns else None,
603
+ points="outliers",
604
+ )
628
605
  elif selected_plot == "bar":
629
- if not group_columns:
630
- fig = px.bar(plot_df, x=x_column,
631
- y=y_columns, barmode="group")
632
- else:
633
- group_column = "__plot_group"
634
- long_df = plot_df[[x_column, group_column, *y_columns]].melt(
635
- id_vars=[x_column, group_column], var_name="variable", value_name="value",
636
- )
637
- fig = px.bar(
638
- long_df, x=x_column, y="value", color=group_column,
639
- facet_row="variable" if len(y_columns) > 1 else None, barmode="group",
640
- )
606
+ fig = px.bar(
607
+ plot_df, x=x_column, y=y_columns[0],
608
+ color="__plot_group" if group_columns else None,
609
+ barmode="group",
610
+ )
611
+ elif selected_plot == "scatter":
612
+ fig = px.scatter(
613
+ plot_df, x=x_column, y=y_columns[0],
614
+ color="__plot_group" if group_columns else None,
615
+ )
641
616
  else:
642
617
  # ``identifier`` identifies an individual input monitor. It
643
618
  # must therefore be used as Plotly's line-group key even when
File without changes
File without changes
File without changes