apmtools 0.2.41__tar.gz → 0.2.42__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.41
3
+ Version: 0.2.42
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>
@@ -12,6 +12,7 @@ Requires-Python: >=3.8
12
12
  Provides-Extra: shiny
13
13
  Requires-Dist: plotly>=6.0; extra == 'shiny'
14
14
  Requires-Dist: shiny>=1.10; extra == 'shiny'
15
+ Requires-Dist: shinyswatch>=0.12; extra == 'shiny'
15
16
  Requires-Dist: shinywidgets>=0.6; extra == 'shiny'
16
17
  Description-Content-Type: text/markdown
17
18
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.2.41"
7
+ version = "0.2.42"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -24,5 +24,6 @@ Homepage = "https://github.com/federlorenz/apmtools"
24
24
  shiny = [
25
25
  "shiny>=1.10",
26
26
  "shinywidgets>=0.6",
27
+ "shinyswatch>=0.12",
27
28
  "plotly>=6.0",
28
29
  ]
@@ -1574,7 +1574,6 @@ class Summary(pd.DataFrame):
1574
1574
 
1575
1575
  return output
1576
1576
 
1577
-
1578
1577
  def get_monitor(self, value):
1579
1578
 
1580
1579
  out = self.subset({"monitor": [value]})
@@ -2066,8 +2065,6 @@ class LcmSeries(ApmSeries):
2066
2065
  def _constructor(self):
2067
2066
  return LcmSeries
2068
2067
 
2069
-
2070
-
2071
2068
  class PolarH10(dict):
2072
2069
  monitor = "polarh10"
2073
2070
  def __init__(self, *args, **kwargs):
@@ -1,4 +1,4 @@
1
- """Optional Shiny integration for apmtools."""
1
+ """Optional Shiny integration for apmtools Summary objects."""
2
2
 
3
3
  from .summary import PLOT_TYPES, create_summary_app, run_summary_app
4
4
 
@@ -0,0 +1,260 @@
1
+ """Figure labels and appearance controls."""
2
+ from __future__ import annotations
3
+ import threading
4
+ from datetime import date, datetime, time, timedelta
5
+ from collections.abc import Sequence
6
+ import numpy as np
7
+ import pandas as pd
8
+ from ..units import upas_units, lascar_units, purple_units
9
+
10
+ from .config import VARIABLE_UNITS, PLOT_TYPES, FREQUENCIES, AGGREGATES, _MPL_PALETTES
11
+
12
+
13
+ def _axis_label(variable: str) -> str:
14
+ """Return a variable label with units, including aggregated columns.
15
+
16
+ Aggregation produces names such as ``pm25_mean`` or ``CO(ppm)_max``.
17
+ Strip only a *recognized* aggregation suffix, and only when the full
18
+ column name does not already have its own units entry.
19
+ """
20
+ name = str(variable)
21
+ unit = VARIABLE_UNITS.get(name)
22
+ if unit is None:
23
+ for suffix in ("count", "max", "mean", "median", "min", "std", "sum"):
24
+ marker = f"_{suffix}"
25
+ if name.endswith(marker):
26
+ unit = VARIABLE_UNITS.get(name[:-len(marker)])
27
+ if unit is not None:
28
+ break
29
+ return f"{name} [{unit}]" if unit else name
30
+
31
+
32
+
33
+ def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
34
+ category_columns=None, x_label_override=None):
35
+ """Label plot axes while retaining special histogram and facet labels."""
36
+ if plot_type == "histogram":
37
+ fig.update_xaxes(title_text=_axis_label(x_column))
38
+ elif plot_type in {"box", "bar"}:
39
+ categories = category_columns or [x_column]
40
+ fig.update_xaxes(title_text=" | ".join(_axis_label(c) for c in categories))
41
+ fig.update_yaxes(title_text=_axis_label(y_columns[0]))
42
+ elif plot_type == "line" and len(y_columns) > 1:
43
+ fig.update_xaxes(title_text=_axis_label(x_column))
44
+ # The values axis is shared by multiple series; facet headings carry
45
+ # the variable-specific unit rather than misleadingly naming one unit.
46
+ fig.update_yaxes(title_text="Value")
47
+ for annotation in fig.layout.annotations:
48
+ label = str(annotation.text)
49
+ if label.startswith("variable="):
50
+ annotation.text = _axis_label(label.split("=", 1)[1])
51
+ else:
52
+ fig.update_xaxes(title_text=_axis_label(x_column))
53
+ if y_columns:
54
+ fig.update_yaxes(title_text=_axis_label(y_columns[0]))
55
+ if x_label_override is not None:
56
+ fig.update_xaxes(title_text=x_label_override)
57
+ return fig
58
+
59
+
60
+
61
+ def _parse_label_map(value):
62
+ """Parse semicolon-separated original=replacement entries."""
63
+ result = {}
64
+ for entry in (value or "").split(";"):
65
+ if "=" in entry:
66
+ key, replacement = entry.split("=", 1)
67
+ if key.strip() and replacement.strip():
68
+ result[key.strip()] = replacement.strip()
69
+ return result
70
+
71
+
72
+
73
+ def _numeric_axis_controls(kind, box_orientation="vertical"):
74
+ """Return axes on which numeric/date tick-density controls apply."""
75
+ if kind in ("line", "scatter", "histogram"):
76
+ return True, True
77
+ if kind == "box" and box_orientation == "horizontal":
78
+ return True, False
79
+ return False, kind in ("box", "bar")
80
+
81
+
82
+
83
+ def _matplotlib_tick_controls(ax, *, kind, box_orientation="vertical", show_grid=True,
84
+ x_major_ticks=6, y_major_ticks=6,
85
+ x_minor_ticks=False, y_minor_ticks=False):
86
+ from matplotlib.ticker import MaxNLocator, AutoMinorLocator
87
+ from matplotlib.dates import AutoDateLocator
88
+ use_x, use_y = _numeric_axis_controls(kind, box_orientation)
89
+ for active, axis, count, minor in ((use_x, ax.xaxis, x_major_ticks, x_minor_ticks),
90
+ (use_y, ax.yaxis, y_major_ticks, y_minor_ticks)):
91
+ if not active:
92
+ continue
93
+ count = max(2, min(30, int(count or 6)))
94
+ if axis.get_scale() == "linear":
95
+ if isinstance(axis.get_major_locator(), AutoDateLocator):
96
+ axis.set_major_locator(AutoDateLocator(minticks=max(2, count - 2), maxticks=count))
97
+ else:
98
+ axis.set_major_locator(MaxNLocator(nbins=count))
99
+ if minor:
100
+ axis.set_minor_locator(AutoMinorLocator())
101
+ else:
102
+ axis.set_minor_locator(__import__("matplotlib.ticker", fromlist=["NullLocator"]).NullLocator())
103
+ axis.grid(bool(show_grid and minor), which="minor")
104
+
105
+
106
+
107
+ def _apply_plot_appearance(fig, *, title, x_label="", y_label="",
108
+ legend_labels="", trace_colors="", palette="Default",
109
+ x_min="", x_max="", y_min="", y_max="",
110
+ template="plotly", legend_position="right", legend_inside=False,
111
+ show_grid=True, font_size=12, line_width=2,
112
+ marker_size=7, line_alpha="", line_style="", scatter_size="", scatter_marker="", scatter_alpha="",
113
+ hist_alpha="", box_showfliers=True, box_orientation="vertical",
114
+ title_font_size=14, axis_font_size=12, tick_font_size=10,
115
+ legend_font_size=12, panel_font_size=14, plot_kind=None,
116
+ x_major_ticks=6, y_major_ticks=6, x_minor_ticks=False, y_minor_ticks=False):
117
+ """Apply per-plot appearance overrides without changing the source data."""
118
+ from plotly.colors import qualitative
119
+
120
+ fig.update_layout(title=title, template=template, font=dict(size=font_size),
121
+ title_font=dict(size=title_font_size),
122
+ legend=dict(font=dict(size=legend_font_size), title_font=dict(size=legend_font_size)))
123
+ fig.update_xaxes(title_font=dict(size=axis_font_size), tickfont=dict(size=tick_font_size))
124
+ fig.update_yaxes(title_font=dict(size=axis_font_size), tickfont=dict(size=tick_font_size))
125
+ if x_label.strip():
126
+ fig.update_xaxes(title_text=x_label.strip())
127
+ if y_label.strip():
128
+ fig.update_yaxes(title_text=y_label.strip())
129
+ fig.update_xaxes(showgrid=show_grid)
130
+ fig.update_yaxes(showgrid=show_grid)
131
+ use_x, use_y = _numeric_axis_controls(plot_kind, box_orientation)
132
+ for active, axis_name, count, minor in ((use_x, "x", x_major_ticks, x_minor_ticks),
133
+ (use_y, "y", y_major_ticks, y_minor_ticks)):
134
+ if not active:
135
+ continue
136
+ update = getattr(fig, f"update_{axis_name}axes")
137
+ update(nticks=max(2, min(30, int(count or 6))), minor=dict(ticks="inside" if minor else "", showgrid=bool(show_grid and minor)))
138
+ for axis, lower, upper in (("x", x_min, x_max), ("y", y_min, y_max)):
139
+ datetime_axis = axis == "x" and any(
140
+ getattr(trace, "type", "") == "scatter" and
141
+ any(isinstance(v, (pd.Timestamp, datetime, np.datetime64)) for v in (trace.x[:3] if trace.x is not None else []))
142
+ for trace in fig.data
143
+ )
144
+ low = _parse_axis_limit(lower, datetime_axis=datetime_axis)
145
+ high = _parse_axis_limit(upper, datetime_axis=datetime_axis)
146
+ if low is not None or high is not None:
147
+ getattr(fig, f"update_{axis}axes")(range=[low, high], autorange=False)
148
+ positions = {
149
+ "right": dict(x=1.02, y=1, xanchor="left", yanchor="top"),
150
+ "left": dict(x=-0.1, y=1, xanchor="right", yanchor="top"),
151
+ "top": dict(x=0.5, y=1.13, xanchor="center", yanchor="bottom", orientation="h"),
152
+ "bottom": dict(x=0.5, y=-0.2, xanchor="center", yanchor="top", orientation="h"),
153
+ }
154
+ legend_settings = positions.get(legend_position, positions["right"]).copy()
155
+ if legend_inside:
156
+ legend_settings = {
157
+ "right": dict(x=0.99, y=0.99, xanchor="right", yanchor="top"),
158
+ "left": dict(x=0.01, y=0.99, xanchor="left", yanchor="top"),
159
+ "top": dict(x=0.5, y=0.99, xanchor="center", yanchor="top", orientation="h"),
160
+ "bottom": dict(x=0.5, y=0.01, xanchor="center", yanchor="bottom", orientation="h"),
161
+ }.get(legend_position, {})
162
+ legend_settings = {**legend_settings, "bgcolor": "rgba(255,255,255,0.7)"}
163
+ fig.update_layout(legend={**legend_settings, "title": {"text": "Legend", "font": {"size": legend_font_size}}, "font": {"size": legend_font_size}})
164
+ labels = _parse_label_map(legend_labels)
165
+ colors = _parse_label_map(trace_colors)
166
+ alphas, styles, sizes, markers, marker_alphas = _series_style_maps(
167
+ line_alpha, line_style, scatter_size, scatter_marker, scatter_alpha)
168
+ histogram_alphas = _parse_label_map(hist_alpha)
169
+ palette_values = getattr(qualitative, palette, None) if palette != "Default" else None
170
+ # Color keys can refer to either original or renamed trace labels.
171
+ for i, trace in enumerate(fig.data):
172
+ original = str(trace.name or "")
173
+ renamed = labels.get(original, original)
174
+ if renamed != original:
175
+ trace.name = renamed
176
+ if getattr(trace, "legendgroup", None) == original:
177
+ trace.legendgroup = renamed
178
+ color = colors.get(original, colors.get(renamed))
179
+ if color is None and palette_values:
180
+ color = palette_values[i % len(palette_values)]
181
+ if color:
182
+ if getattr(trace, "line", None) is not None:
183
+ trace.line.color = color
184
+ if getattr(trace, "marker", None) is not None:
185
+ trace.marker.color = color
186
+ if getattr(trace, "fillcolor", None) is not None and trace.type == "histogram":
187
+ trace.marker.color = color
188
+ if plot_kind == "histogram":
189
+ trace.opacity = _bounded_float(histogram_alphas, original, 0.65, 0, 1)
190
+ if plot_kind == "box":
191
+ trace.boxpoints = "outliers" if box_showfliers else False
192
+ if box_orientation == "horizontal":
193
+ old_x, old_y = trace.x, trace.y
194
+ trace.x, trace.y = old_y, old_x
195
+ trace.orientation = "h"
196
+ if getattr(trace, "line", None) is not None and plot_kind == "line":
197
+ trace.line.width = line_width
198
+ if plot_kind == "line":
199
+ trace.line.dash = {"-": "solid", ":": "dot", "--": "dash"}[_line_style(styles, original)]
200
+ trace.opacity = _bounded_float(alphas, original, 1, 0, 1)
201
+ if getattr(trace, "marker", None) is not None and trace.type in ("scatter", "scattergl"):
202
+ if plot_kind == "scatter":
203
+ trace.marker.size = _bounded_float(sizes, original, 7, 0.1, 100)
204
+ trace.marker.symbol = {"o": "circle", "x": "x"}[_scatter_marker(markers, original)]
205
+ trace.opacity = _bounded_float(marker_alphas, original, 1, 0, 1)
206
+ else:
207
+ trace.marker.size = marker_size
208
+ if plot_kind == "box" and box_orientation == "horizontal":
209
+ x_title = fig.layout.xaxis.title.text
210
+ y_title = fig.layout.yaxis.title.text
211
+ fig.update_xaxes(title_text=y_title, type="linear", autorange=True)
212
+ fig.update_yaxes(title_text=x_title, type="category", autorange=True)
213
+ return fig
214
+
215
+
216
+
217
+ def _series_style_maps(line_alpha="", line_style="", scatter_size="", scatter_marker="", scatter_alpha=""):
218
+ """Parse per-trace styles, keyed by the original legend/series name."""
219
+ return tuple(_parse_label_map(v) for v in (line_alpha, line_style, scatter_size, scatter_marker, scatter_alpha))
220
+
221
+
222
+
223
+ def _bounded_float(mapping, name, default, low, high):
224
+ try:
225
+ value = float(mapping.get(name, default))
226
+ return value if np.isfinite(value) and low <= value <= high else float(default)
227
+ except (ValueError, TypeError):
228
+ return float(default)
229
+
230
+
231
+
232
+ def _line_style(mapping, name):
233
+ return {"solid": "-", "dotted": ":", "dashed": "--", "-": "-", ":": ":", "--": "--"}.get(
234
+ mapping.get(name, "solid").strip().lower(), "-")
235
+
236
+
237
+
238
+ def _scatter_marker(mapping, name):
239
+ return {"dot": "o", "cross": "x", "o": "o", "x": "x"}.get(
240
+ mapping.get(name, "dot").strip().lower(), "o")
241
+
242
+
243
+
244
+ def _parse_axis_limit(value, *, datetime_axis=False):
245
+ """Parse numeric bounds or an ISO timestamp for a datetime X axis."""
246
+ if value is None or not str(value).strip():
247
+ return None
248
+ if datetime_axis:
249
+ try:
250
+ parsed = pd.to_datetime(value, errors="raise")
251
+ if not pd.isna(parsed):
252
+ return parsed
253
+ except (TypeError, ValueError, OverflowError):
254
+ return None
255
+ try:
256
+ number = float(value)
257
+ return number if np.isfinite(number) else None
258
+ except (TypeError, ValueError):
259
+ return None
260
+
@@ -0,0 +1,98 @@
1
+ """Column classification and default selections."""
2
+ from __future__ import annotations
3
+ import threading
4
+ from datetime import date, datetime, time, timedelta
5
+ from collections.abc import Sequence
6
+ import numpy as np
7
+ import pandas as pd
8
+ from ..units import upas_units, lascar_units, purple_units
9
+
10
+ from .config import VARIABLE_UNITS, PLOT_TYPES, FREQUENCIES, AGGREGATES, _MPL_PALETTES
11
+
12
+
13
+ def _default_x(data: pd.DataFrame) -> str:
14
+ """Return ``time`` when available, otherwise the first column."""
15
+ if "datetime" in data.columns:
16
+ return "datetime"
17
+ datetime_columns = [
18
+ column
19
+ for column in data.columns
20
+ if pd.api.types.is_datetime64_any_dtype(data[column])
21
+ ]
22
+ return str(datetime_columns[0] if datetime_columns else data.columns[0])
23
+
24
+
25
+
26
+ def _default_group(data: pd.DataFrame) -> str | None:
27
+ """Return ``identifier`` when available."""
28
+ return "identifier" if "identifier" in data.columns else None
29
+
30
+
31
+
32
+ def _numeric_columns(data: pd.DataFrame) -> list[str]:
33
+ """Return numeric column names as strings."""
34
+ return sorted([
35
+ str(column)
36
+ for column in data.columns
37
+ if pd.api.types.is_numeric_dtype(data[column])
38
+ ])
39
+
40
+
41
+
42
+ def _categorical_columns(data: pd.DataFrame) -> list[str]:
43
+ """Return categorical column names as strings."""
44
+ return sorted(
45
+ [
46
+ str(column)
47
+ for column in data.columns
48
+ if not pd.api.types.is_numeric_dtype(data[column])
49
+ and not pd.api.types.is_datetime64_any_dtype(data[column])
50
+ ],
51
+ key=str.casefold,
52
+ )
53
+
54
+
55
+
56
+ def _datetime_columns(data: pd.DataFrame) -> list[str]:
57
+ """Return datetime column names as strings."""
58
+ return sorted(
59
+ [
60
+ str(column)
61
+ for column in data.columns
62
+ if pd.api.types.is_datetime64_any_dtype(data[column])
63
+ ],
64
+ key=str.casefold,
65
+ )
66
+
67
+
68
+
69
+ def _timedelta_columns(data: pd.DataFrame) -> list[str]:
70
+ """Return timedelta column names as strings."""
71
+ return sorted(
72
+ [
73
+ str(column)
74
+ for column in data.columns
75
+ if pd.api.types.is_timedelta64_dtype(data[column])
76
+ ],
77
+ key=str.casefold,
78
+ )
79
+
80
+
81
+
82
+ def _alphabetical(values) -> list[str]:
83
+ """Return string values sorted alphabetically, case-insensitively."""
84
+ return sorted((str(value) for value in values), key=str.casefold)
85
+
86
+
87
+
88
+ def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> list[str]:
89
+ """Validate and normalise an initial Y selection."""
90
+ numeric = _numeric_columns(data)
91
+ if y is None:
92
+ return [column for column in numeric if column != x][:1]
93
+ values = [y] if isinstance(y, str) else list(y)
94
+ missing = [column for column in values if column not in data.columns]
95
+ if missing:
96
+ raise ValueError(f"Unknown y column(s): {missing}")
97
+ return [str(column) for column in values]
98
+
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+ import threading
3
+ from datetime import date, datetime, time, timedelta
4
+ from collections.abc import Sequence
5
+ import numpy as np
6
+ import pandas as pd
7
+ from ..units import upas_units, lascar_units, purple_units
8
+
9
+ VARIABLE_UNITS = {**upas_units, **lascar_units, **purple_units}
10
+
11
+
12
+ PLOT_TYPES = {
13
+ "bar": "Bar",
14
+ "box": "Boxplot",
15
+ "histogram": "Histogram",
16
+ "line": "Line",
17
+ "scatter": "Scatter",
18
+ }
19
+
20
+
21
+ FREQUENCIES = {
22
+ "None": None,
23
+ "Day": "day",
24
+ "Hour": "hour",
25
+ "Minute": "minute",
26
+ "Second": "second",
27
+ "Week": "week",
28
+ "Weekday": "weekday",
29
+ }
30
+
31
+
32
+ AGGREGATES = {
33
+ "None": None,
34
+ "Count": "count",
35
+ "Maximum": "max",
36
+ "Mean": "mean",
37
+ "Median": "median",
38
+ "Minimum": "min",
39
+ "Standard deviation": "std",
40
+ "Sum": "sum",
41
+ }
42
+
43
+
44
+ _MPL_PALETTES = {
45
+ "Default": ["#636EFA", "#EF553B", "#00CC96", "#AB63FA", "#FFA15A", "#19D3F3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52"],
46
+ "Plotly": ["#636EFA", "#EF553B", "#00CC96", "#AB63FA", "#FFA15A", "#19D3F3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52"],
47
+ "D3": ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"],
48
+ "G10": ["#3366CC", "#DC3912", "#FF9900", "#109618", "#990099", "#0099C6", "#DD4477", "#66AA00", "#B82E2E", "#316395"],
49
+ "T10": ["#4C78A8", "#F58518", "#E45756", "#72B7B2", "#54A24B", "#EECA3B", "#B279A2", "#FF9DA6", "#9D755D", "#BAB0AC"],
50
+ "Set1": ["#E41A1C", "#377EB8", "#4DAF4A", "#984EA3", "#FF7F00", "#FFFF33", "#A65628", "#F781BF", "#999999"],
51
+ "Set2": ["#66C2A5", "#FC8D62", "#8DA0CB", "#E78AC3", "#A6D854", "#FFD92F", "#E5C494", "#B3B3B3"],
52
+ "Pastel": ["#AEC6CF", "#FFB347", "#77DD77", "#CFCFC4", "#B39EB5", "#FF6961", "#FDFD96", "#CB99C9"],
53
+ "Dark24": ["#2E91E5", "#E15F99", "#1CA71C", "#FB0D0D", "#DA16FF", "#222A2A", "#B68100", "#750D86", "#EB663B", "#511CFB", "#00A08B", "#FB00D1", "#FC0080", "#B2828D", "#6C7C32", "#778AAE", "#862A16", "#A777F1", "#620042", "#1616A7", "#DA60CA", "#6C4516", "#0D2A63", "#AF0038"],
54
+ }
55
+