apmtools 0.1.63__tar.gz → 0.2.1__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.1.063
3
+ Version: 0.2.001
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>
@@ -9,6 +9,10 @@ Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Operating System :: OS Independent
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Requires-Python: >=3.8
12
+ Provides-Extra: shiny
13
+ Requires-Dist: plotly>=6.0; extra == 'shiny'
14
+ Requires-Dist: shiny>=1.10; extra == 'shiny'
15
+ Requires-Dist: shinywidgets>=0.6; extra == 'shiny'
12
16
  Description-Content-Type: text/markdown
13
17
 
14
18
  # apmtools
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.1.063"
7
+ version = "0.2.001"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -19,3 +19,10 @@ classifiers = [
19
19
 
20
20
  [project.urls]
21
21
  Homepage = "https://github.com/federlorenz/apmtools"
22
+
23
+ [project.optional-dependencies]
24
+ shiny = [
25
+ "shiny>=1.10",
26
+ "shinywidgets>=0.6",
27
+ "plotly>=6.0",
28
+ ]
@@ -1397,6 +1397,74 @@ class Summary(pd.DataFrame):
1397
1397
 
1398
1398
  return output
1399
1399
 
1400
+ def plot_app(
1401
+ self,
1402
+ x=None,
1403
+ y=None,
1404
+ group_by=None,
1405
+ plot_type="line",
1406
+ title="Summary plot",
1407
+ host="127.0.0.1",
1408
+ port=0,
1409
+ launch_browser=True,
1410
+ daemon=True,
1411
+ ):
1412
+ """
1413
+ Launch an interactive Shiny plotting application for this Summary.
1414
+
1415
+ Parameters
1416
+ ----------
1417
+ x : str, optional
1418
+ Initial X-axis or categorical column.
1419
+ y : str or sequence of str, optional
1420
+ Initial numeric variable or variables.
1421
+ group_by : str, optional
1422
+ Initial grouping column.
1423
+ plot_type : {"line", "bar", "histogram", "box"}, default="line"
1424
+ Initial plot type. The user can change the plot type in the app.
1425
+ title : str, default="Summary plot"
1426
+ Initial plot title.
1427
+ host : str, default="127.0.0.1"
1428
+ Server host.
1429
+ port : int, default=0
1430
+ Server port. Zero selects an available port.
1431
+ launch_browser : bool, default=True
1432
+ Open the app in the default browser.
1433
+ daemon : bool, default=True
1434
+ Run the server thread as a daemon.
1435
+
1436
+ Returns
1437
+ -------
1438
+ threading.Thread
1439
+ Background thread running the Shiny server.
1440
+
1441
+ Notes
1442
+ -----
1443
+ Install the optional dependencies with:
1444
+
1445
+ pip install "apmtools[shiny]"
1446
+ """
1447
+ try:
1448
+ from .shiny import run_summary_app
1449
+ except ImportError as exc:
1450
+ raise ImportError(
1451
+ "Summary.plot_app() requires the optional Shiny dependencies. "
1452
+ 'Install them with: pip install "apmtools[shiny]"'
1453
+ ) from exc
1454
+
1455
+ return run_summary_app(
1456
+ self,
1457
+ x=x,
1458
+ y=y,
1459
+ group_by=group_by,
1460
+ plot_type=plot_type,
1461
+ title=title,
1462
+ host=host,
1463
+ port=port,
1464
+ launch_browser=launch_browser,
1465
+ daemon=daemon,
1466
+ )
1467
+
1400
1468
  class Apm(pd.DataFrame):
1401
1469
  monitor = "apm"
1402
1470
  def __init__(self, *args, **kwargs):
@@ -0,0 +1,5 @@
1
+ """Optional Shiny integration for apmtools."""
2
+
3
+ from .summary import PLOT_TYPES, create_summary_app, run_summary_app
4
+
5
+ __all__ = ["PLOT_TYPES", "create_summary_app", "run_summary_app"]
@@ -0,0 +1,353 @@
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