apmtools 0.2.17__tar.gz → 0.2.19__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.017
3
+ Version: 0.2.019
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.017"
7
+ version = "0.2.019"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import threading
6
6
  from collections.abc import Sequence
7
-
7
+ import numpy as np
8
8
  import pandas as pd
9
9
 
10
10
 
@@ -16,7 +16,6 @@ PLOT_TYPES = {
16
16
  "scatter": "Scatter",
17
17
  }
18
18
 
19
-
20
19
  # ``None`` means row-level plotting. The other values are passed directly to
21
20
  # Summary.group_stat(frequency=...).
22
21
  FREQUENCIES = {
@@ -100,7 +99,8 @@ def _timedelta_columns(data: pd.DataFrame) -> list[str]:
100
99
  key=str.casefold,
101
100
  )
102
101
 
103
- def _default_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> list[str]:
102
+
103
+ def _normalise_y(data: pd.DataFrame, x: str, y: str | Sequence[str] | None) -> list[str]:
104
104
  """Validate and normalise an initial Y selection."""
105
105
  numeric = _numeric_columns(data)
106
106
  if y is None:
@@ -179,15 +179,120 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate):
179
179
  return result
180
180
 
181
181
 
182
- def _aggregated_value_columns(data, value_columns, aggregate):
183
- """Return the columns produced by Summary.group_stat()."""
184
- columns = []
185
- for column in value_columns:
186
- aggregated = f"{column}_{aggregate}"
187
- if aggregated in data.columns:
188
- columns.append(aggregated)
189
- return columns
182
+ def _histogram_figure(
183
+ data: pd.DataFrame,
184
+ x_column: str,
185
+ *,
186
+ group_column: str | None = None,
187
+ bins: int = 30,
188
+ density: bool = False,
189
+ stacked: bool = False,
190
+ histtype: str = "bar",
191
+ cumulative: int = 0,
192
+ ):
193
+ """Build a Matplotlib-compatible histogram using shared bin boundaries.
190
194
 
195
+ ``cumulative`` is 0 (off), 1 (forward), or -1 (reverse).
196
+ Density for stacked data is normalized across all groups; otherwise each
197
+ group's histogram is normalized independently.
198
+ """
199
+ import plotly.graph_objects as go
200
+
201
+ if histtype not in {"bar", "barstacked", "step", "stepfilled"}:
202
+ raise ValueError(f"Unknown histtype: {histtype}")
203
+ if cumulative not in {-1, 0, 1}:
204
+ raise ValueError("cumulative must be -1, 0 or 1")
205
+ if bins < 1:
206
+ raise ValueError("bins must be positive")
207
+
208
+ stacked = bool(stacked or histtype == "barstacked")
209
+ labels = (
210
+ list(pd.unique(data[group_column].dropna()))
211
+ if group_column is not None
212
+ else ["All observations"]
213
+ )
214
+ arrays = []
215
+ for label in labels:
216
+ rows = data.loc[data[group_column] == label] if group_column else data
217
+ values = pd.to_numeric(
218
+ rows[x_column], errors="coerce").to_numpy(dtype=float)
219
+ arrays.append(values[np.isfinite(values)])
220
+
221
+ all_values = np.concatenate(arrays) if arrays else np.array([])
222
+ fig = go.Figure()
223
+ if not len(all_values):
224
+ fig.update_layout(title="No numerical observations")
225
+ return fig
226
+
227
+ edges = np.histogram_bin_edges(all_values, bins=bins)
228
+ widths = np.diff(edges)
229
+ counts = np.array([np.histogram(v, bins=edges)[0]
230
+ for v in arrays], dtype=float)
231
+ totals = counts.sum(axis=1)
232
+ grand_total = totals.sum()
233
+
234
+ if density:
235
+ if stacked:
236
+ heights = counts / (grand_total * widths)
237
+ else:
238
+ heights = np.divide(
239
+ counts, totals[:, None] * widths[None, :],
240
+ out=np.zeros_like(counts),
241
+ where=(totals[:, None] > 0),
242
+ )
243
+ else:
244
+ heights = counts.copy()
245
+
246
+ if cumulative:
247
+ # Cumulative density is a CDF/survival curve, not the sum of
248
+ # bin-wise probability densities. It must integrate bin mass.
249
+ mass = counts / grand_total if (density and stacked) else counts.copy()
250
+ if density and not stacked:
251
+ mass = np.divide(
252
+ counts, totals[:, None],
253
+ out=np.zeros_like(counts),
254
+ where=(totals[:, None] > 0),
255
+ )
256
+ heights = (
257
+ np.cumsum(mass, axis=1)
258
+ if cumulative == 1
259
+ else np.flip(np.cumsum(np.flip(mass, axis=1), axis=1), axis=1)
260
+ )
261
+
262
+ for index, label in enumerate(labels):
263
+ values = heights[index]
264
+ if histtype in ("bar", "barstacked"):
265
+ fig.add_trace(go.Bar(
266
+ name=str(label),
267
+ x=(edges[:-1] + edges[1:]) / 2,
268
+ y=values,
269
+ width=widths,
270
+ marker_line_width=0,
271
+ ))
272
+ else:
273
+ # Horizontal steps start/end at the shared bin boundaries.
274
+ xx = np.repeat(edges, 2)[1:-1]
275
+ yy = np.repeat(values, 2)
276
+ fig.add_trace(go.Scatter(
277
+ name=str(label),
278
+ x=xx,
279
+ y=yy,
280
+ mode="lines",
281
+ fill="tozeroy" if histtype == "stepfilled" else None,
282
+ stackgroup="histogram" if stacked and histtype == "stepfilled" else None,
283
+ ))
284
+
285
+ fig.update_layout(
286
+ barmode="stack" if stacked else "group",
287
+ xaxis_title=x_column,
288
+ yaxis_title=(
289
+ "Cumulative probability" if cumulative and density
290
+ else "Cumulative count" if cumulative
291
+ else "Probability density" if density
292
+ else "Count"
293
+ ),
294
+ )
295
+ return fig
191
296
 
192
297
  def _frequency_timedelta(frequency: str) -> pd.Timedelta | None:
193
298
  """Convert a timestamp aggregation frequency to its expected spacing."""
@@ -300,16 +405,17 @@ def create_summary_app(
300
405
  columns = _alphabetical(summary.columns)
301
406
  column_map = {str(c): c for c in summary.columns}
302
407
  numeric_columns = _alphabetical(_numeric_columns(summary))
303
- categorical_columns = _alphabetical(_categorical_columns(summary))
304
- datetime_columns = _alphabetical(_datetime_columns(summary))
305
- timedelta_columns = _alphabetical(_timedelta_columns(summary))
306
-
408
+ categorical_columns = _categorical_columns(summary)
409
+ datetime_columns = _datetime_columns(summary)
410
+ group_columns = _alphabetical(
411
+ c for c in columns if not pd.api.types.is_numeric_dtype(summary[column_map[c]])
412
+ )
307
413
 
308
414
  x = _default_x(summary) if x is None else str(x)
309
415
  if x not in column_map:
310
416
  raise ValueError(f"Unknown x column: {x!r}")
311
417
 
312
- initial_y = _default_y(summary, x, y)
418
+ initial_y = _normalise_y(summary, x, y)
313
419
  if group_by is None:
314
420
  initial_groups = [_default_group(summary)] if _default_group(
315
421
  summary) is not None else []
@@ -320,6 +426,8 @@ def create_summary_app(
320
426
  unknown_groups = [c for c in initial_groups if c not in column_map]
321
427
  if unknown_groups:
322
428
  raise ValueError(f"Unknown group_by column(s): {unknown_groups!r}")
429
+ # Numeric columns are not offered by the Group by selector.
430
+ initial_groups = [c for c in initial_groups if c in group_columns]
323
431
 
324
432
  if plot_type not in PLOT_TYPES:
325
433
  raise ValueError(
@@ -336,7 +444,7 @@ def create_summary_app(
336
444
  ui.output_ui("y_control"),
337
445
  ui.input_selectize(
338
446
  "group_by", "Group by",
339
- choices={c: c for c in columns},
447
+ choices={c: c for c in group_columns},
340
448
  selected=initial_groups,
341
449
  multiple=True,
342
450
  options={"placeholder": "Select one or more grouping columns"},
@@ -373,6 +481,17 @@ def create_summary_app(
373
481
  "input.plot_type === 'histogram'",
374
482
  ui.input_numeric("bins", "Histogram bins",
375
483
  value=30, min=1, max=200, step=1),
484
+ ui.input_select("hist_density", "Density", choices={
485
+ "False": "False", "True": "True"}, selected="False"),
486
+ ui.input_select("hist_stacked", "Stacked", choices={
487
+ "False": "False", "True": "True"}, selected="False"),
488
+ ui.input_select("hist_type", "Histogram type", choices={
489
+ "bar": "bar", "barstacked": "barstacked",
490
+ "step": "step", "stepfilled": "stepfilled",
491
+ }, selected="bar"),
492
+ ui.input_select("hist_cumulative", "Cumulative", choices={
493
+ "0": "False", "1": "True (ascending)", "-1": "-1 (descending)",
494
+ }, selected="0"),
376
495
  ),
377
496
  ui.input_text("title", "Plot title", value=title),
378
497
  ui.help_text(
@@ -595,13 +714,15 @@ def create_summary_app(
595
714
  )
596
715
 
597
716
  if selected_plot == "histogram":
598
- fig = px.histogram(
717
+ fig = _histogram_figure(
599
718
  plot_df,
600
- x=x_column,
601
- color="__plot_group" if group_columns else None,
602
- nbins=int(input.bins()),
603
- barmode="overlay",
604
- opacity=0.45 if group_columns else 1.0,
719
+ x_column,
720
+ group_column="__plot_group" if group_columns else None,
721
+ bins=int(input.bins()),
722
+ density=input.hist_density() == "True",
723
+ stacked=input.hist_stacked() == "True",
724
+ histtype=input.hist_type(),
725
+ cumulative=int(input.hist_cumulative()),
605
726
  )
606
727
  elif selected_plot == "box":
607
728
  fig = px.box(
File without changes
File without changes
File without changes