apmtools 0.2.20__tar.gz → 0.2.22__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.020
3
+ Version: 0.2.022
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.020"
7
+ version = "0.2.022"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -1,13 +1,13 @@
1
- """Interactive Plotly/Shiny application for apmtools Summary objects."""
1
+ """Interactive Plotly/Shiny application for :class:`apmtools.Summary`."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import threading
6
6
  from collections.abc import Sequence
7
+
7
8
  import numpy as np
8
9
  import pandas as pd
9
10
 
10
-
11
11
  PLOT_TYPES = {
12
12
  "bar": "Bar",
13
13
  "box": "Boxplot",
@@ -19,11 +19,11 @@ PLOT_TYPES = {
19
19
  # ``None`` means row-level plotting. The other values are passed directly to
20
20
  # Summary.group_stat(frequency=...).
21
21
  FREQUENCIES = {
22
+ "Day": "day",
23
+ "Hour": "hour",
24
+ "Minute": "minute",
22
25
  "None (raw data)": None,
23
26
  "Second": "second",
24
- "Minute": "minute",
25
- "Hour": "hour",
26
- "Day": "day",
27
27
  "Week": "week",
28
28
  "Weekday": "weekday",
29
29
  }
@@ -38,22 +38,24 @@ AGGREGATES = {
38
38
  "Sum": "sum",
39
39
  }
40
40
 
41
+
41
42
  def _default_x(data: pd.DataFrame) -> str:
42
- """Return the first datetime column, or otherwise the first column."""
43
+ """Return ``time`` when available, otherwise the first column."""
44
+ if "time" in data.columns:
45
+ return "time"
43
46
  datetime_columns = [
44
47
  column
45
48
  for column in data.columns
46
49
  if pd.api.types.is_datetime64_any_dtype(data[column])
47
50
  ]
48
- if "time" in datetime_columns:
49
- return "time"
50
- else:
51
- return str(datetime_columns[0] if datetime_columns else data.columns[0])
51
+ return str(datetime_columns[0] if datetime_columns else data.columns[0])
52
+
52
53
 
53
54
  def _default_group(data: pd.DataFrame) -> str | None:
54
55
  """Return ``identifier`` when available."""
55
56
  return "identifier" if "identifier" in data.columns else None
56
57
 
58
+
57
59
  def _numeric_columns(data: pd.DataFrame) -> list[str]:
58
60
  """Return numeric column names as strings."""
59
61
  return sorted([
@@ -100,6 +102,11 @@ def _timedelta_columns(data: pd.DataFrame) -> list[str]:
100
102
  )
101
103
 
102
104
 
105
+ def _alphabetical(values) -> list[str]:
106
+ """Return string values sorted alphabetically, case-insensitively."""
107
+ return sorted((str(value) for value in values), key=str.casefold)
108
+
109
+
103
110
  def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> list[str]:
104
111
  """Validate and normalise an initial Y selection."""
105
112
  numeric = _numeric_columns(data)
@@ -112,15 +119,10 @@ def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> l
112
119
  return [str(column) for column in values]
113
120
 
114
121
 
115
- def _alphabetical(values) -> list[str]:
116
- """Return string values sorted alphabetically, case-insensitively."""
117
- return sorted((str(value) for value in values), key=str.casefold)
118
-
119
-
120
-
121
122
  def _subset_choices(data: pd.DataFrame, column: str) -> tuple[dict[str, str], dict[str, object]]:
122
123
  """Build stable Shiny choices and their corresponding original values."""
123
- values = pd.unique(data[column].dropna())
124
+ values = sorted(pd.unique(data[column].dropna()),
125
+ key=lambda value: str(value).casefold())
124
126
  choices: dict[str, str] = {}
125
127
  value_map: dict[str, object] = {}
126
128
  for i, value in enumerate(values):
@@ -179,6 +181,16 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate):
179
181
  return result
180
182
 
181
183
 
184
+ def _aggregated_value_columns(data, value_columns, aggregate):
185
+ """Return the columns produced by Summary.group_stat()."""
186
+ columns = []
187
+ for column in value_columns:
188
+ aggregated = f"{column}_{aggregate}"
189
+ if aggregated in data.columns:
190
+ columns.append(aggregated)
191
+ return columns
192
+
193
+
182
194
  def _histogram_figure(
183
195
  data: pd.DataFrame,
184
196
  x_column: str,
@@ -294,6 +306,7 @@ def _histogram_figure(
294
306
  )
295
307
  return fig
296
308
 
309
+
297
310
  def _frequency_timedelta(frequency: str) -> pd.Timedelta | None:
298
311
  """Convert a timestamp aggregation frequency to its expected spacing."""
299
312
  if not frequency or frequency == "weekday":
@@ -436,7 +449,72 @@ def create_summary_app(
436
449
  subset_columns = columns
437
450
 
438
451
  app_ui = ui.page_sidebar(
439
- ui.sidebar(
452
+ ui.div(
453
+ ui.tags.style("""
454
+ /* Allow the main pane to consume all width remaining after the sidebar. */
455
+ .bslib-sidebar-layout > .main,
456
+ .bslib-sidebar-layout > main,
457
+ .bslib-sidebar-layout .main {
458
+ min-width: 0;
459
+ width: 100%;
460
+ max-width: none;
461
+ }
462
+ /* Remove the default Bootstrap container width limit, if present. */
463
+ .container, .container-fluid { max-width: none !important; }
464
+ .summary-plot-container {
465
+ width: 100%;
466
+ max-width: none;
467
+ aspect-ratio: 2 / 1;
468
+ min-width: 0;
469
+ min-height: 0;
470
+ padding: 0;
471
+ margin: 0;
472
+ position: relative;
473
+ }
474
+ .summary-plot-container > .html-widget-output,
475
+ .summary-plot-container > .shiny-plot-output,
476
+ .summary-plot-container > #plot {
477
+ width: 100% !important;
478
+ height: 100% !important;
479
+ max-width: none !important;
480
+ }
481
+ """),
482
+ ui.tags.script("""
483
+ // Plotly sometimes retains the pixel width measured before the
484
+ // Shiny sidebar and widget have finished laying out.
485
+ (() => {
486
+ function resizePlot() {
487
+ const container = document.querySelector('.summary-plot-container');
488
+ if (!container || !window.Plotly) return;
489
+ const graph = container.querySelector('.js-plotly-plot');
490
+ if (graph) window.Plotly.Plots.resize(graph);
491
+ }
492
+ const observer = new ResizeObserver(resizePlot);
493
+ const attach = () => {
494
+ const container = document.querySelector('.summary-plot-container');
495
+ if (!container) return false;
496
+ observer.observe(container);
497
+ resizePlot();
498
+ return true;
499
+ };
500
+ document.addEventListener('DOMContentLoaded', attach);
501
+ window.addEventListener('resize', resizePlot);
502
+ if (!attach()) {
503
+ const startup = new MutationObserver((_, watcher) => {
504
+ if (attach()) watcher.disconnect();
505
+ });
506
+ startup.observe(document.documentElement, {
507
+ childList: true, subtree: true
508
+ });
509
+ }
510
+ })();
511
+ """),
512
+ ui.div(
513
+ output_widget("plot", height="100%", width="100%"),
514
+ class_="summary-plot-container",
515
+ ),
516
+ ),
517
+ sidebar=ui.sidebar(
440
518
  ui.h4("Plot controls"),
441
519
  ui.input_select("plot_type", "Plot type",
442
520
  choices=PLOT_TYPES, selected=plot_type),
@@ -500,13 +578,6 @@ def create_summary_app(
500
578
  "Summary.group_stat()."
501
579
  ),
502
580
  ),
503
- ui.div(
504
- output_widget("plot", height="100%", width="100%"),
505
- style=(
506
- "width: 100%; max-width: 100%; aspect-ratio: 16 / 9; "
507
- "min-height: 0; margin: 0; padding: 0;"
508
- ),
509
- ),
510
581
  )
511
582
 
512
583
  def server(input, output, session):
File without changes
File without changes
File without changes