apmtools 0.2.16__tar.gz → 0.2.17__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.017
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.017"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -9,12 +9,14 @@ 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
 
19
+
18
20
  # ``None`` means row-level plotting. The other values are passed directly to
19
21
  # Summary.group_stat(frequency=...).
20
22
  FREQUENCIES = {
@@ -28,13 +30,13 @@ FREQUENCIES = {
28
30
  }
29
31
 
30
32
  AGGREGATES = {
33
+ "Count": "count",
34
+ "Maximum": "max",
31
35
  "Mean": "mean",
32
36
  "Median": "median",
33
- "Sum": "sum",
34
37
  "Minimum": "min",
35
- "Maximum": "max",
36
- "Count": "count",
37
38
  "Standard deviation": "std",
39
+ "Sum": "sum",
38
40
  }
39
41
 
40
42
  def _default_x(data: pd.DataFrame) -> str:
@@ -98,7 +100,7 @@ def _timedelta_columns(data: pd.DataFrame) -> list[str]:
98
100
  key=str.casefold,
99
101
  )
100
102
 
101
- def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> list[str]:
103
+ def _default_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)
104
106
  if y is None:
@@ -298,13 +300,16 @@ def create_summary_app(
298
300
  columns = _alphabetical(summary.columns)
299
301
  column_map = {str(c): c for c in summary.columns}
300
302
  numeric_columns = _alphabetical(_numeric_columns(summary))
301
- categorical_columns = _categorical_columns(summary)
303
+ categorical_columns = _alphabetical(_categorical_columns(summary))
304
+ datetime_columns = _alphabetical(_datetime_columns(summary))
305
+ timedelta_columns = _alphabetical(_timedelta_columns(summary))
306
+
302
307
 
303
308
  x = _default_x(summary) if x is None else str(x)
304
309
  if x not in column_map:
305
310
  raise ValueError(f"Unknown x column: {x!r}")
306
311
 
307
- initial_y = _normalise_y(summary, x, y)
312
+ initial_y = _default_y(summary, x, y)
308
313
  if group_by is None:
309
314
  initial_groups = [_default_group(summary)] if _default_group(
310
315
  summary) is not None else []
@@ -364,8 +369,11 @@ def create_summary_app(
364
369
  "datetime_end", "End datetime",
365
370
  value="", placeholder="YYYY-MM-DD or YYYY-MM-DD HH:MM:SS",
366
371
  ),
367
- ui.input_numeric("bins", "Histogram bins",
368
- value=30, min=1, max=200, step=1),
372
+ ui.panel_conditional(
373
+ "input.plot_type === 'histogram'",
374
+ ui.input_numeric("bins", "Histogram bins",
375
+ value=30, min=1, max=200, step=1),
376
+ ),
369
377
  ui.input_text("title", "Plot title", value=title),
370
378
  ui.help_text(
371
379
  "Subset and datetime filters are applied before aggregation. "
@@ -382,39 +390,53 @@ def create_summary_app(
382
390
  def server(input, output, session):
383
391
  @render.ui
384
392
  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,
393
+ kind = input.plot_type()
394
+ if kind in ("box", "bar"):
395
+ choices = categorical_columns
396
+ multiple = True
397
+ elif kind == "line":
398
+ choices = datetime_columns
399
+ multiple = False
400
+ else:
401
+ choices = numeric_columns
402
+ multiple = False
403
+
404
+ if not choices:
405
+ return ui.help_text(f"No compatible X variables for {kind}.")
406
+ selected = [x] if x in choices else [choices[0]]
407
+ if multiple:
408
+ return ui.input_selectize(
409
+ "x", "X-axis / categories",
410
+ choices={c: c for c in choices},
411
+ selected=selected, multiple=True,
396
412
  )
397
413
  return ui.input_select(
398
- "x", "X-axis / category",
399
- choices={c: c for c in columns},
400
- selected=x,
414
+ "x", "X-axis / variable",
415
+ choices={c: c for c in choices},
416
+ selected=selected[0],
401
417
  )
402
418
 
403
419
  @render.ui
404
420
  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)
421
+ kind = input.plot_type()
422
+ if kind == "histogram":
423
+ return ui.div()
424
+ if not numeric_columns:
425
+ return ui.help_text("No numeric Y variables available.")
426
+ selected = next(
427
+ (v for v in initial_y if v in numeric_columns), numeric_columns[0])
428
+ if kind in ("box", "bar", "scatter"):
409
429
  return ui.input_select(
410
430
  "y", "Y-axis / variable",
411
431
  choices={c: c for c in numeric_columns},
412
432
  selected=selected,
413
433
  )
414
434
  return ui.input_selectize(
415
- "y", "Y-axis / variable",
435
+ "y", "Y-axis / variables",
416
436
  choices={c: c for c in numeric_columns},
417
- selected=initial_y, multiple=True,
437
+ selected=[v for v in initial_y if v in numeric_columns] or [
438
+ selected],
439
+ multiple=True,
418
440
  )
419
441
 
420
442
  @render.ui
@@ -474,89 +496,92 @@ def create_summary_app(
474
496
  @reactive.calc
475
497
  def selected_data():
476
498
  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:
499
+ kind = input.plot_type()
500
+ x_input = input.x()
501
+ selected_x = ([x_input] if isinstance(
502
+ x_input, str) else list(x_input or []))
503
+ if not selected_x:
504
+ return pd.DataFrame()
505
+
506
+ if kind == "histogram":
507
+ value_names = selected_x
508
+ else:
509
+ y_input = input.y()
510
+ value_names = ([y_input] if isinstance(
511
+ y_input, str) else list(y_input or []))
512
+ if not value_names:
481
513
  return pd.DataFrame()
482
514
 
483
- y_columns = [column_map[c] for c in selected_y]
484
515
  selected_groups = list(input.group_by())
485
516
  grouping_by = [column_map[c] for c in selected_groups]
486
517
  frequency = FREQUENCIES[input.frequency()]
487
518
  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.
519
+ value_columns = [column_map[c] for c in value_names]
520
+
521
+ if kind in ("box", "bar"):
522
+ for c in selected_x:
523
+ if column_map[c] not in grouping_by:
524
+ grouping_by.append(column_map[c])
525
+ if kind == "scatter" and frequency is not None:
526
+ # Both numerical axes must be aggregated on the same rows.
527
+ value_columns = list(dict.fromkeys(
528
+ [column_map[selected_x[0]], *value_columns]
529
+ ))
501
530
  if frequency is not None:
502
- current = _aggregate(current, y_columns,
503
- frequency, grouping_by, aggregate)
504
-
531
+ current = _aggregate(
532
+ current, value_columns, frequency, grouping_by, aggregate
533
+ )
505
534
  return current
506
535
 
507
536
  @render_plotly
508
537
  def plot():
509
538
  plot_df = selected_data()
510
539
  selected_plot = input.plot_type()
511
- selected_x = input.x()
512
- y_input = input.y()
540
+ x_input = input.x()
541
+ selected_x = ([x_input] if isinstance(
542
+ x_input, str) else list(x_input or []))
543
+ y_input = None if selected_plot == "histogram" else input.y()
513
544
  selected_y = ([y_input] if isinstance(
514
545
  y_input, str) else list(y_input or []))
515
546
  selected_groups = list(input.group_by())
516
547
  frequency = FREQUENCIES[input.frequency()]
517
548
 
518
- if plot_df.empty or not selected_y:
549
+ if plot_df.empty or not selected_x or (
550
+ selected_plot != "histogram" and not selected_y
551
+ ):
519
552
  return px.scatter(title="No data for the selected filters")
520
553
 
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]
554
+ aggregate = AGGREGATES[input.aggregate()]
525
555
 
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
556
+ def mapped(column):
557
+ original = column_map[column]
558
+ return f"{original}_{aggregate}" if frequency is not None else original
542
559
 
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
- )
560
+ if selected_plot == "histogram":
561
+ x_column = mapped(selected_x[0])
562
+ y_columns = []
563
+ elif selected_plot in ("box", "bar"):
564
+ x_columns = [column_map[c] for c in selected_x]
565
+ x_column = x_columns[0]
566
+ y_columns = [mapped(selected_y[0])]
567
+ elif selected_plot == "scatter":
568
+ x_column = mapped(selected_x[0])
569
+ y_columns = [mapped(selected_y[0])]
550
570
  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
- )
571
+ x_column = frequency if frequency is not None else column_map[selected_x[0]]
572
+ y_columns = [mapped(c) for c in selected_y]
573
+
574
+ required = [x_column, *y_columns]
575
+ if selected_plot in ("box", "bar"):
576
+ required.extend(x_columns)
577
+ if any(c not in plot_df.columns for c in required):
578
+ return px.scatter(title="Selected variables are unavailable after aggregation")
579
+
580
+ if selected_plot in ("box", "bar"):
581
+ plot_df = plot_df.copy()
582
+ plot_df["__plot_category"] = plot_df[x_columns].astype(
583
+ str).agg(" | ".join, axis=1)
584
+ x_column = "__plot_category"
560
585
 
561
586
  # Multiple grouping columns are supported by group_stat(). For
562
587
  # plotting, Plotly needs one color/category column, so combine the
@@ -570,74 +595,31 @@ def create_summary_app(
570
595
  )
571
596
 
572
597
  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
- )
598
+ fig = px.histogram(
599
+ plot_df,
600
+ x=x_column,
601
+ color="__plot_group" if group_columns else None,
602
+ nbins=int(input.bins()),
603
+ barmode="overlay",
604
+ opacity=0.45 if group_columns else 1.0,
605
+ )
610
606
  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
- )
607
+ fig = px.box(
608
+ plot_df, x=x_column, y=y_columns[0],
609
+ color="__plot_group" if group_columns else None,
610
+ points="outliers",
611
+ )
628
612
  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
- )
613
+ fig = px.bar(
614
+ plot_df, x=x_column, y=y_columns[0],
615
+ color="__plot_group" if group_columns else None,
616
+ barmode="group",
617
+ )
618
+ elif selected_plot == "scatter":
619
+ fig = px.scatter(
620
+ plot_df, x=x_column, y=y_columns[0],
621
+ color="__plot_group" if group_columns else None,
622
+ )
641
623
  else:
642
624
  # ``identifier`` identifies an individual input monitor. It
643
625
  # must therefore be used as Plotly's line-group key even when
File without changes
File without changes
File without changes