apmtools 0.1.63__tar.gz → 0.2.2__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.002
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.002"
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
+ ]
@@ -837,7 +837,7 @@ class Dataset(DictionaryPlus):
837
837
  if (k in value.m.keys()) and (value.m[k] == v):
838
838
  value.m[k] = None
839
839
 
840
- def monitor(self,value):
840
+ def get_monitor(self,value):
841
841
  return self.subset({"monitor":[value]})
842
842
 
843
843
  class Group(pd.DataFrame):
@@ -1397,6 +1397,77 @@ class Summary(pd.DataFrame):
1397
1397
 
1398
1398
  return output
1399
1399
 
1400
+ def get_monitor(self, value):
1401
+ return self.subset({"monitor": [value]})
1402
+
1403
+ def plot_app(
1404
+ self,
1405
+ x=None,
1406
+ y=None,
1407
+ group_by=None,
1408
+ plot_type="line",
1409
+ title="Summary plot",
1410
+ host="127.0.0.1",
1411
+ port=0,
1412
+ launch_browser=True,
1413
+ daemon=True,
1414
+ ):
1415
+ """
1416
+ Launch an interactive Shiny plotting application for this Summary.
1417
+
1418
+ Parameters
1419
+ ----------
1420
+ x : str, optional
1421
+ Initial X-axis or categorical column.
1422
+ y : str or sequence of str, optional
1423
+ Initial numeric variable or variables.
1424
+ group_by : str, optional
1425
+ Initial grouping column.
1426
+ plot_type : {"line", "bar", "histogram", "box"}, default="line"
1427
+ Initial plot type. The user can change the plot type in the app.
1428
+ title : str, default="Summary plot"
1429
+ Initial plot title.
1430
+ host : str, default="127.0.0.1"
1431
+ Server host.
1432
+ port : int, default=0
1433
+ Server port. Zero selects an available port.
1434
+ launch_browser : bool, default=True
1435
+ Open the app in the default browser.
1436
+ daemon : bool, default=True
1437
+ Run the server thread as a daemon.
1438
+
1439
+ Returns
1440
+ -------
1441
+ threading.Thread
1442
+ Background thread running the Shiny server.
1443
+
1444
+ Notes
1445
+ -----
1446
+ Install the optional dependencies with:
1447
+
1448
+ pip install "apmtools[shiny]"
1449
+ """
1450
+ try:
1451
+ from .shiny import run_summary_app
1452
+ except ImportError as exc:
1453
+ raise ImportError(
1454
+ "Summary.plot_app() requires the optional Shiny dependencies. "
1455
+ 'Install them with: pip install "apmtools[shiny]"'
1456
+ ) from exc
1457
+
1458
+ return run_summary_app(
1459
+ self,
1460
+ x=x,
1461
+ y=y,
1462
+ group_by=group_by,
1463
+ plot_type=plot_type,
1464
+ title=title,
1465
+ host=host,
1466
+ port=port,
1467
+ launch_browser=launch_browser,
1468
+ daemon=daemon,
1469
+ )
1470
+
1400
1471
  class Apm(pd.DataFrame):
1401
1472
  monitor = "apm"
1402
1473
  def __init__(self, *args, **kwargs):
@@ -1707,6 +1778,10 @@ class Upas(Apm):
1707
1778
  def _constructor_sliced(self):
1708
1779
  return UpasSeries
1709
1780
 
1781
+ @property
1782
+ def units(self):
1783
+ return upas_units
1784
+
1710
1785
  class UpasSeries(ApmSeries):
1711
1786
  def __init__(self, *args, **kwargs):
1712
1787
  ApmSeries.__init__(self, *args, **kwargs)
@@ -1733,6 +1808,10 @@ class Lascar(Apm):
1733
1808
  def _constructor_sliced(self):
1734
1809
  return LascarSeries
1735
1810
 
1811
+ @property
1812
+ def units(self):
1813
+ return lascar_units
1814
+
1736
1815
  class LascarSeries(ApmSeries):
1737
1816
  def __init__(self, *args, **kwargs):
1738
1817
  ApmSeries.__init__(self, *args, **kwargs)
@@ -1758,6 +1837,10 @@ class Purple(Apm):
1758
1837
  def _constructor_sliced(self):
1759
1838
  return PurpleSeries
1760
1839
 
1840
+ @property
1841
+ def units(self):
1842
+ return purple_units
1843
+
1761
1844
  class PurpleSeries(ApmSeries):
1762
1845
  def __init__(self, *args, **kwargs):
1763
1846
  ApmSeries.__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