apmtools 0.2.5__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.
- {apmtools-0.2.5 → apmtools-0.2.6}/PKG-INFO +1 -1
- {apmtools-0.2.5 → apmtools-0.2.6}/pyproject.toml +1 -1
- apmtools-0.2.6/src/apmtools/shiny/summary.py +435 -0
- apmtools-0.2.5/src/apmtools/shiny/summary.py +0 -353
- {apmtools-0.2.5 → apmtools-0.2.6}/.gitignore +0 -0
- {apmtools-0.2.5 → apmtools-0.2.6}/LICENSE +0 -0
- {apmtools-0.2.5 → apmtools-0.2.6}/README.md +0 -0
- {apmtools-0.2.5 → apmtools-0.2.6}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.5 → apmtools-0.2.6}/src/apmtools/classes.py +0 -0
- {apmtools-0.2.5 → apmtools-0.2.6}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.5 → apmtools-0.2.6}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.5 → apmtools-0.2.6}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.5 → apmtools-0.2.6}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.5 → apmtools-0.2.6}/src/apmtools/units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: apmtools
|
|
3
|
-
Version: 0.2.
|
|
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>
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|