apmtools 0.2.4__tar.gz → 0.2.6__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.004
3
+ Version: 0.2.006
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.004"
7
+ version = "0.2.006"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -970,7 +970,7 @@ class Summary(pd.DataFrame):
970
970
  if type(time_start) is str:
971
971
  time_start = parse(time_start).time()
972
972
  if type(time_end) is str:
973
- time_start = parse(time_end).time()
973
+ time_end = parse(time_end).time()
974
974
  if type(date_start) is str:
975
975
  date_start = parse(date_start)
976
976
  if type(date_end) is str:
@@ -1526,7 +1526,7 @@ class Apm(pd.DataFrame):
1526
1526
  if type(time_start) is str:
1527
1527
  time_start = parse(time_start).time()
1528
1528
  if type(time_end) is str:
1529
- time_start = parse(time_end).time()
1529
+ time_end = parse(time_end).time()
1530
1530
  if type(date_start) is str:
1531
1531
  date_start = parse(date_start)
1532
1532
  if type(date_end) is str:
@@ -0,0 +1,435 @@
1
+ """Interactive Plotly/Shiny application for apmtools Summary objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Sequence
7
+
8
+ import pandas as pd
9
+
10
+
11
+ PLOT_TYPES = {
12
+ "line": "Line",
13
+ "bar": "Bar",
14
+ "histogram": "Histogram",
15
+ "box": "Boxplot",
16
+ }
17
+
18
+ # ``None`` means row-level plotting. The other values are passed directly to
19
+ # Summary.group_stat(frequency=...).
20
+ FREQUENCIES = {
21
+ "None (raw data)": None,
22
+ "Second": "second",
23
+ "Minute": "minute",
24
+ "Hour": "hour",
25
+ "Day": "day",
26
+ "Week": "week",
27
+ "Weekday": "weekday",
28
+ }
29
+
30
+ AGGREGATES = {
31
+ "Mean": "mean",
32
+ "Median": "median",
33
+ "Sum": "sum",
34
+ "Minimum": "min",
35
+ "Maximum": "max",
36
+ "Count": "count",
37
+ "Standard deviation": "std",
38
+ }
39
+
40
+ def _default_x(data: pd.DataFrame) -> str:
41
+ """Return the first datetime column, or otherwise the first column."""
42
+ datetime_columns = [
43
+ column
44
+ for column in data.columns
45
+ if pd.api.types.is_datetime64_any_dtype(data[column])
46
+ ]
47
+ return str(datetime_columns[0] if datetime_columns else data.columns[0])
48
+
49
+ def _default_group(data: pd.DataFrame) -> str | None:
50
+ """Return ``identifier`` when available."""
51
+ return "identifier" if "identifier" in data.columns else None
52
+
53
+ def _numeric_columns(data: pd.DataFrame) -> list[str]:
54
+ """Return numeric column names as strings."""
55
+ return [
56
+ str(column)
57
+ for column in data.columns
58
+ if pd.api.types.is_numeric_dtype(data[column])
59
+ ]
60
+
61
+
62
+ def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> list[str]:
63
+ """Validate and normalise an initial Y selection."""
64
+ numeric = _numeric_columns(data)
65
+ if y is None:
66
+ return [column for column in numeric if column != x][:1]
67
+ values = [y] if isinstance(y, str) else list(y)
68
+ missing = [column for column in values if column not in data.columns]
69
+ if missing:
70
+ raise ValueError(f"Unknown y column(s): {missing}")
71
+ return [str(column) for column in values]
72
+
73
+
74
+ def _subset_choices(data: pd.DataFrame, column: str) -> tuple[dict[str, str], dict[str, object]]:
75
+ """Build stable Shiny choices and their corresponding original values."""
76
+ values = pd.unique(data[column].dropna())
77
+ choices: dict[str, str] = {}
78
+ value_map: dict[str, object] = {}
79
+ for i, value in enumerate(values):
80
+ key = str(i)
81
+ choices[key] = str(value)
82
+ value_map[key] = value
83
+ return choices, value_map
84
+
85
+
86
+ def _apply_filters(data, subset_column, subset_values, datetime_start, datetime_end):
87
+ """Apply Summary.subset() and Summary.dtfilter() in that order."""
88
+ filtered = data
89
+
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})
95
+
96
+ if datetime_start or datetime_end:
97
+ filtered = filtered.dtfilter(
98
+ date_start=datetime_start or None,
99
+ date_end=datetime_end or None,
100
+ )
101
+
102
+ return filtered
103
+
104
+
105
+ def _aggregate(data, value_columns, frequency, grouping_by, aggregate):
106
+ """Aggregate through ``Summary.group_stat``.
107
+
108
+ ``group_by`` is a plotting/grouping choice for raw observations. It only
109
+ becomes a statistical grouping when a frequency is selected.
110
+ """
111
+ if frequency is None:
112
+ return data
113
+
114
+ result = data.group_stat(
115
+ value_cols=value_columns,
116
+ frequency=frequency,
117
+ grouping_by=grouping_by,
118
+ aggregate=[aggregate],
119
+ )
120
+ if result is None:
121
+ raise ValueError(
122
+ "Summary.group_stat() returned None. Check the selected variables, "
123
+ "grouping columns, frequency, and completeness settings."
124
+ )
125
+ return result
126
+
127
+
128
+ def _aggregated_value_columns(data, value_columns, aggregate):
129
+ """Return the columns produced by Summary.group_stat()."""
130
+ columns = []
131
+ for column in value_columns:
132
+ aggregated = f"{column}_{aggregate}"
133
+ if aggregated in data.columns:
134
+ columns.append(aggregated)
135
+ return columns
136
+
137
+
138
+ def create_summary_app(
139
+ data: pd.DataFrame,
140
+ *,
141
+ x: str | None = None,
142
+ y: str | Sequence[str] | None = None,
143
+ group_by: str | Sequence[str] | None = None,
144
+ plot_type: str = "line",
145
+ title: str = "Summary plot",
146
+ ):
147
+ """Create, but do not start, an interactive Shiny app for a Summary."""
148
+ from shiny import App, reactive, render, ui
149
+ from shinywidgets import output_widget, render_plotly
150
+ import plotly.express as px
151
+
152
+ # Keep the Summary object intact: subset(), dtfilter() and group_stat()
153
+ # are methods on Summary and are intentionally used below.
154
+ summary = data.copy(deep=True)
155
+ if not isinstance(summary, pd.DataFrame):
156
+ raise TypeError("data must be a pandas DataFrame/Summary")
157
+ if summary.empty:
158
+ raise ValueError(
159
+ "Cannot create a Shiny application from an empty Summary.")
160
+
161
+ columns = [str(c) for c in summary.columns]
162
+ column_map = {str(c): c for c in summary.columns}
163
+ numeric_columns = _numeric_columns(summary)
164
+
165
+ x = _default_x(summary) if x is None else str(x)
166
+ if x not in column_map:
167
+ raise ValueError(f"Unknown x column: {x!r}")
168
+
169
+ initial_y = _normalise_y(summary, x, y)
170
+ if group_by is None:
171
+ initial_groups = [_default_group(summary)] if _default_group(
172
+ summary) is not None else []
173
+ elif isinstance(group_by, str):
174
+ initial_groups = [group_by]
175
+ else:
176
+ initial_groups = list(group_by)
177
+ unknown_groups = [c for c in initial_groups if c not in column_map]
178
+ if unknown_groups:
179
+ raise ValueError(f"Unknown group_by column(s): {unknown_groups!r}")
180
+
181
+ if plot_type not in PLOT_TYPES:
182
+ raise ValueError(
183
+ f"Unknown plot_type {plot_type!r}. Choose one of: {', '.join(PLOT_TYPES)}")
184
+
185
+ subset_columns = ["None", *columns]
186
+
187
+ app_ui = ui.page_sidebar(
188
+ ui.sidebar(
189
+ ui.h4("Plot controls"),
190
+ ui.input_select("plot_type", "Plot type",
191
+ choices=PLOT_TYPES, selected=plot_type),
192
+ ui.input_select("x", "X-axis / category",
193
+ choices=columns, selected=x),
194
+ ui.input_selectize(
195
+ "y", "Y-axis / variable", choices=numeric_columns,
196
+ selected=initial_y, multiple=True,
197
+ ),
198
+ ui.input_selectize(
199
+ "group_by", "Group by",
200
+ choices={c: c for c in columns},
201
+ selected=initial_groups,
202
+ multiple=True,
203
+ options={"placeholder": "Select one or more grouping columns"},
204
+ ),
205
+ ui.hr(),
206
+ ui.h5("Aggregation"),
207
+ ui.input_select(
208
+ "frequency", "Frequency", choices=FREQUENCIES,
209
+ selected="None (raw data)",
210
+ ),
211
+ ui.input_select(
212
+ "aggregate", "Statistic", choices=AGGREGATES,
213
+ selected="Mean",
214
+ ),
215
+ ui.hr(),
216
+ ui.h5("Subset"),
217
+ ui.input_select("subset_column", "Column",
218
+ choices=subset_columns, selected="None"),
219
+ ui.input_selectize(
220
+ "subset_values", "Values", choices={}, selected=[], multiple=True,
221
+ options={"placeholder": "Select values"},
222
+ ),
223
+ ui.hr(),
224
+ ui.h5("Datetime filter"),
225
+ ui.input_text(
226
+ "datetime_start", "Start datetime",
227
+ value="", placeholder="YYYY-MM-DD or YYYY-MM-DD HH:MM:SS",
228
+ ),
229
+ ui.input_text(
230
+ "datetime_end", "End datetime",
231
+ value="", placeholder="YYYY-MM-DD or YYYY-MM-DD HH:MM:SS",
232
+ ),
233
+ ui.input_numeric("bins", "Histogram bins",
234
+ value=30, min=1, max=200, step=1),
235
+ ui.input_text("title", "Plot title", value=title),
236
+ ui.help_text(
237
+ "Subset and datetime filters are applied before aggregation. "
238
+ "When a frequency is selected, aggregation is performed with "
239
+ "Summary.group_stat()."
240
+ ),
241
+ ),
242
+ ui.card(
243
+ ui.card_header("Interactive plot"),
244
+ output_widget("plot", height="75vh"),
245
+ ),
246
+ )
247
+
248
+ 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:
255
+ choices, _ = _subset_choices(
256
+ summary, column_map[selected_column])
257
+ ui.update_selectize("subset_values", choices=choices, selected=[])
258
+
259
+ @reactive.calc
260
+ def filtered_summary():
261
+ subset_column = input.subset_column()
262
+ subset_values = list(input.subset_values())
263
+ start = input.datetime_start().strip()
264
+ 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
+ )
272
+
273
+ @reactive.calc
274
+ def selected_data():
275
+ current = filtered_summary()
276
+ selected_y = list(input.y())
277
+ if not selected_y:
278
+ return pd.DataFrame()
279
+
280
+ y_columns = [column_map[c] for c in selected_y]
281
+ selected_groups = list(input.group_by())
282
+ grouping_by = [column_map[c] for c in selected_groups]
283
+ frequency = FREQUENCIES[input.frequency()]
284
+ aggregate = AGGREGATES[input.aggregate()]
285
+
286
+ # All selected grouping columns are passed directly to
287
+ # Summary.group_stat() when a frequency is selected. With raw
288
+ # data they remain plotting-only grouping columns.
289
+ if frequency is not None:
290
+ current = _aggregate(current, y_columns,
291
+ frequency, grouping_by, aggregate)
292
+
293
+ return current
294
+
295
+ @render_plotly
296
+ def plot():
297
+ plot_df = selected_data()
298
+ selected_plot = input.plot_type()
299
+ selected_x = input.x()
300
+ selected_y = list(input.y())
301
+ selected_groups = list(input.group_by())
302
+ frequency = FREQUENCIES[input.frequency()]
303
+
304
+ if plot_df.empty or not selected_y:
305
+ return px.scatter(title="No data for the selected filters")
306
+
307
+ # Keep the original selected Y columns separate from the columns
308
+ # produced by group_stat(). For example, selecting ``pm25`` and
309
+ # hourly means produces ``pm25_mean`` in the aggregated result.
310
+ selected_y_columns = [column_map[c] for c in selected_y]
311
+
312
+ # Summary.group_stat() creates the frequency column itself.
313
+ # For example, frequency="hour" creates an ``hour`` column,
314
+ # while an aggregate such as mean creates ``pm25_mean``.
315
+ # Therefore the aggregated frequency column replaces the
316
+ # originally selected X-axis column.
317
+ if frequency is not None:
318
+ aggregate = AGGREGATES[input.aggregate()]
319
+ y_columns = _aggregated_value_columns(
320
+ plot_df, selected_y_columns, aggregate
321
+ )
322
+ x_column = frequency
323
+
324
+ if x_column not in plot_df.columns:
325
+ return px.scatter(
326
+ title=(
327
+ f"Aggregation did not produce the expected "
328
+ f"'{x_column}' column"
329
+ )
330
+ )
331
+ else:
332
+ y_columns = [
333
+ c for c in selected_y_columns if c in plot_df.columns
334
+ ]
335
+ x_column = column_map[selected_x]
336
+
337
+ if not y_columns:
338
+ return px.scatter(
339
+ title="Selected variables are not available after aggregation"
340
+ )
341
+
342
+ # Multiple grouping columns are supported by group_stat(). For
343
+ # plotting, Plotly needs one color/category column, so combine the
344
+ # selected grouping columns into a readable composite label.
345
+ group_columns = [column_map[c] for c in selected_groups
346
+ if column_map[c] in plot_df.columns]
347
+ if group_columns:
348
+ plot_df = plot_df.copy()
349
+ plot_df["__plot_group"] = (
350
+ plot_df[group_columns].astype(str).agg(" | ".join, axis=1)
351
+ )
352
+
353
+ if selected_plot == "histogram":
354
+ long_df = plot_df[y_columns].melt(
355
+ var_name="variable", value_name="value")
356
+ fig = px.histogram(
357
+ long_df, x="value", color="variable", nbins=int(input.bins()),
358
+ barmode="overlay" if len(y_columns) > 1 else "relative",
359
+ )
360
+ elif selected_plot == "box":
361
+ if not group_columns:
362
+ long_df = plot_df[y_columns].melt(
363
+ var_name="variable", value_name="value")
364
+ fig = px.box(long_df, x="variable",
365
+ y="value", points="outliers")
366
+ else:
367
+ group_column = "__plot_group"
368
+ long_df = plot_df[[group_column, *y_columns]].melt(
369
+ id_vars=[group_column], var_name="variable", value_name="value",
370
+ )
371
+ fig = px.box(long_df, x="variable", y="value",
372
+ color=group_column, points="outliers")
373
+ elif selected_plot == "bar":
374
+ if not group_columns:
375
+ fig = px.bar(plot_df, x=x_column,
376
+ y=y_columns, barmode="group")
377
+ else:
378
+ group_column = "__plot_group"
379
+ long_df = plot_df[[x_column, group_column, *y_columns]].melt(
380
+ id_vars=[x_column, group_column], var_name="variable", value_name="value",
381
+ )
382
+ fig = px.bar(
383
+ long_df, x=x_column, y="value", color=group_column,
384
+ facet_row="variable" if len(y_columns) > 1 else None, barmode="group",
385
+ )
386
+ else:
387
+ if not group_columns:
388
+ fig = px.line(plot_df, x=x_column, y=y_columns)
389
+ else:
390
+ group_column = "__plot_group"
391
+ long_df = plot_df[[x_column, group_column, *y_columns]].melt(
392
+ id_vars=[x_column, group_column], var_name="variable", value_name="value",
393
+ )
394
+ fig = px.line(
395
+ long_df, x=x_column, y="value", color=group_column,
396
+ facet_row="variable" if len(y_columns) > 1 else None,
397
+ )
398
+
399
+ fig.update_layout(
400
+ title=input.title(),
401
+ hovermode="x unified" if selected_plot == "line" else "closest",
402
+ margin=dict(l=60, r=30, t=70, b=60),
403
+ )
404
+ return fig
405
+
406
+ return App(app_ui, server)
407
+
408
+
409
+ def run_summary_app(
410
+ data: pd.DataFrame,
411
+ *,
412
+ x: str | None = None,
413
+ y: str | Sequence[str] | None = None,
414
+ group_by: str | Sequence[str] | None = None,
415
+ plot_type: str = "line",
416
+ title: str = "Summary plot",
417
+ host: str = "127.0.0.1",
418
+ port: int = 0,
419
+ launch_browser: bool = True,
420
+ daemon: bool = True,
421
+ ):
422
+ """Create and start the Shiny app in a background thread."""
423
+ from shiny import run_app
424
+
425
+ app = create_summary_app(
426
+ data, x=x, y=y, group_by=group_by, plot_type=plot_type, title=title)
427
+
428
+ def _run() -> None:
429
+ run_app(app, host=host, port=port,
430
+ launch_browser=launch_browser, dev_mode=False)
431
+
432
+ thread = threading.Thread(
433
+ target=_run, name="apmtools-shiny-summary", daemon=daemon)
434
+ thread.start()
435
+ return thread
@@ -1,353 +0,0 @@
1
- """Interactive Plotly/Shiny application for apmtools Summary objects."""
2
-
3
- from __future__ import annotations
4
-
5
- import threading
6
- from collections.abc import Sequence
7
-
8
- import pandas as pd
9
-
10
-
11
- PLOT_TYPES = {
12
- "line": "Line",
13
- "bar": "Bar",
14
- "histogram": "Histogram",
15
- "box": "Boxplot",
16
- }
17
-
18
-
19
- def _default_x(data: pd.DataFrame) -> str:
20
- """Return the first datetime column, or otherwise the first column."""
21
- datetime_columns = [
22
- column
23
- for column in data.columns
24
- if pd.api.types.is_datetime64_any_dtype(data[column])
25
- ]
26
- return str(datetime_columns[0] if datetime_columns else data.columns[0])
27
-
28
-
29
- def _numeric_columns(data: pd.DataFrame) -> list[str]:
30
- """Return numeric column names as strings."""
31
- return [
32
- str(column)
33
- for column in data.columns
34
- if pd.api.types.is_numeric_dtype(data[column])
35
- ]
36
-
37
-
38
- def _normalise_y(
39
- data: pd.DataFrame,
40
- x: str,
41
- y: str | Sequence[str] | None,
42
- ) -> list[str]:
43
- """Validate and normalise an initial Y selection."""
44
- if y is None:
45
- return [column for column in _numeric_columns(data) if column != x][:1]
46
-
47
- values = [y] if isinstance(y, str) else list(y)
48
- missing = [column for column in values if column not in data.columns]
49
- if missing:
50
- raise ValueError(f"Unknown y column(s): {missing}")
51
- return [str(column) for column in values]
52
-
53
-
54
- def create_summary_app(
55
- data: pd.DataFrame,
56
- *,
57
- x: str | None = None,
58
- y: str | Sequence[str] | None = None,
59
- group_by: str | None = None,
60
- plot_type: str = "line",
61
- title: str = "Summary plot",
62
- ):
63
- """
64
- Create, but do not start, a Shiny application for a Summary/DataFrame.
65
-
66
- Supported plot types are ``line``, ``bar``, ``histogram`` and ``box``.
67
- """
68
- from shiny import App, reactive, render, ui
69
- from shinywidgets import output_widget, render_plotly
70
- import plotly.express as px
71
-
72
- df = pd.DataFrame(data).copy()
73
- if df.empty:
74
- raise ValueError("Cannot create a Shiny application from an empty Summary.")
75
-
76
- original_columns = list(df.columns)
77
- column_map = {str(column): column for column in original_columns}
78
- columns = list(column_map)
79
- numeric_columns = _numeric_columns(df)
80
-
81
- if x is None:
82
- x = _default_x(df)
83
- else:
84
- x = str(x)
85
- if x not in column_map:
86
- raise ValueError(f"Unknown x column: {x!r}")
87
-
88
- initial_y = _normalise_y(df, column_map[x], y)
89
-
90
- if group_by is not None:
91
- group_by = str(group_by)
92
- if group_by not in column_map:
93
- raise ValueError(f"Unknown group_by column: {group_by!r}")
94
-
95
- if plot_type not in PLOT_TYPES:
96
- raise ValueError(
97
- f"Unknown plot_type {plot_type!r}. "
98
- f"Choose one of: {', '.join(PLOT_TYPES)}"
99
- )
100
-
101
- app_ui = ui.page_sidebar(
102
- ui.sidebar(
103
- ui.h4("Plot controls"),
104
-
105
- ui.input_select(
106
- "plot_type",
107
- "Plot type",
108
- choices=PLOT_TYPES,
109
- selected=plot_type,
110
- ),
111
-
112
- ui.input_select(
113
- "x",
114
- "X-axis / category",
115
- choices=columns,
116
- selected=x,
117
- ),
118
-
119
- ui.input_selectize(
120
- "y",
121
- "Y-axis / variable",
122
- choices=numeric_columns,
123
- selected=initial_y,
124
- multiple=True,
125
- ),
126
-
127
- ui.input_select(
128
- "group_by",
129
- "Group by",
130
- choices={"None": "None", **{c: c for c in columns}},
131
- selected=group_by if group_by is not None else "None",
132
- ),
133
-
134
- ui.input_numeric(
135
- "bins",
136
- "Histogram bins",
137
- value=30,
138
- min=1,
139
- max=200,
140
- step=1,
141
- ),
142
-
143
- ui.input_text(
144
- "title",
145
- "Plot title",
146
- value=title,
147
- ),
148
-
149
- ui.help_text(
150
- "Line: ordered X values and one or more Y variables. "
151
- "Bar: categorical X and numeric Y. "
152
- "Histogram: distribution of numeric variables. "
153
- "Boxplot: distribution of numeric variables, optionally grouped."
154
- ),
155
- ),
156
-
157
- ui.card(
158
- ui.card_header("Interactive plot"),
159
- output_widget("plot", height="75vh"),
160
- ),
161
- )
162
-
163
- def server(input, output, session):
164
- @reactive.calc
165
- def selected_data() -> pd.DataFrame:
166
- selected_plot = input.plot_type()
167
- selected_x = input.x()
168
- selected_y = list(input.y())
169
- selected_group = input.group_by()
170
-
171
- if selected_plot == "histogram":
172
- if not selected_y:
173
- return pd.DataFrame()
174
- use_columns = [column_map[c] for c in selected_y]
175
- else:
176
- if not selected_y:
177
- return pd.DataFrame()
178
- use_columns = [
179
- column_map[selected_x],
180
- *(column_map[c] for c in selected_y),
181
- ]
182
-
183
- if selected_group != "None":
184
- group_column = column_map[selected_group]
185
- if group_column not in use_columns:
186
- use_columns.append(group_column)
187
-
188
- return df.loc[:, use_columns].copy()
189
-
190
- @render_plotly
191
- def plot():
192
- plot_df = selected_data()
193
- selected_plot = input.plot_type()
194
- selected_x = input.x()
195
- selected_y = list(input.y())
196
- selected_group = input.group_by()
197
-
198
- if plot_df.empty or not selected_y:
199
- return px.scatter(title="Select at least one numeric variable")
200
-
201
- y_columns = [column_map[c] for c in selected_y]
202
-
203
- if selected_plot == "histogram":
204
- # Plotly accepts multiple columns through y with a long-form
205
- # transformation; melt gives consistent legends.
206
- long_df = plot_df[y_columns].melt(
207
- var_name="variable",
208
- value_name="value",
209
- )
210
- fig = px.histogram(
211
- long_df,
212
- x="value",
213
- color="variable",
214
- nbins=int(input.bins()),
215
- barmode="overlay" if len(y_columns) > 1 else "relative",
216
- )
217
-
218
- elif selected_plot == "box":
219
- if selected_group == "None":
220
- long_df = plot_df[y_columns].melt(
221
- var_name="variable",
222
- value_name="value",
223
- )
224
- fig = px.box(
225
- long_df,
226
- x="variable",
227
- y="value",
228
- points="outliers",
229
- )
230
- else:
231
- group_column = column_map[selected_group]
232
- long_df = plot_df[
233
- [group_column, *y_columns]
234
- ].melt(
235
- id_vars=[group_column],
236
- var_name="variable",
237
- value_name="value",
238
- )
239
- fig = px.box(
240
- long_df,
241
- x="variable",
242
- y="value",
243
- color=group_column,
244
- points="outliers",
245
- )
246
-
247
- elif selected_plot == "bar":
248
- x_column = column_map[selected_x]
249
- if selected_group == "None":
250
- fig = px.bar(
251
- plot_df,
252
- x=x_column,
253
- y=y_columns,
254
- barmode="group",
255
- )
256
- else:
257
- group_column = column_map[selected_group]
258
- long_df = plot_df.melt(
259
- id_vars=[x_column, group_column],
260
- value_vars=y_columns,
261
- var_name="variable",
262
- value_name="value",
263
- )
264
- fig = px.bar(
265
- long_df,
266
- x=x_column,
267
- y="value",
268
- color=group_column,
269
- facet_row="variable" if len(y_columns) > 1 else None,
270
- barmode="group",
271
- )
272
-
273
- else: # line
274
- x_column = column_map[selected_x]
275
- if selected_group == "None":
276
- fig = px.line(
277
- plot_df,
278
- x=x_column,
279
- y=y_columns,
280
- )
281
- else:
282
- group_column = column_map[selected_group]
283
- long_df = plot_df.melt(
284
- id_vars=[x_column, group_column],
285
- value_vars=y_columns,
286
- var_name="variable",
287
- value_name="value",
288
- )
289
- fig = px.line(
290
- long_df,
291
- x=x_column,
292
- y="value",
293
- color=group_column,
294
- facet_row="variable" if len(y_columns) > 1 else None,
295
- )
296
-
297
- fig.update_layout(
298
- title=input.title(),
299
- hovermode="x unified"
300
- if selected_plot == "line"
301
- else "closest",
302
- margin=dict(l=60, r=30, t=70, b=60),
303
- )
304
- return fig
305
-
306
- return App(app_ui, server)
307
-
308
-
309
- def run_summary_app(
310
- data: pd.DataFrame,
311
- *,
312
- x: str | None = None,
313
- y: str | Sequence[str] | None = None,
314
- group_by: str | None = None,
315
- plot_type: str = "line",
316
- title: str = "Summary plot",
317
- host: str = "127.0.0.1",
318
- port: int = 0,
319
- launch_browser: bool = True,
320
- daemon: bool = True,
321
- ):
322
- """
323
- Create and start the Shiny app in a background thread.
324
-
325
- ``port=0`` asks the operating system to select an available port.
326
- """
327
- from shiny import run_app
328
-
329
- app = create_summary_app(
330
- data,
331
- x=x,
332
- y=y,
333
- group_by=group_by,
334
- plot_type=plot_type,
335
- title=title,
336
- )
337
-
338
- def _run() -> None:
339
- run_app(
340
- app,
341
- host=host,
342
- port=port,
343
- launch_browser=launch_browser,
344
- dev_mode=False,
345
- )
346
-
347
- thread = threading.Thread(
348
- target=_run,
349
- name="apmtools-shiny-summary",
350
- daemon=daemon,
351
- )
352
- thread.start()
353
- return thread
File without changes
File without changes
File without changes
File without changes