apmtools 0.2.18__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.018
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.018"
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
 
@@ -179,6 +179,121 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate):
179
179
  return result
180
180
 
181
181
 
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.
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
296
+
182
297
  def _frequency_timedelta(frequency: str) -> pd.Timedelta | None:
183
298
  """Convert a timestamp aggregation frequency to its expected spacing."""
184
299
  if not frequency or frequency == "weekday":
@@ -366,6 +481,17 @@ def create_summary_app(
366
481
  "input.plot_type === 'histogram'",
367
482
  ui.input_numeric("bins", "Histogram bins",
368
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"),
369
495
  ),
370
496
  ui.input_text("title", "Plot title", value=title),
371
497
  ui.help_text(
@@ -588,13 +714,15 @@ def create_summary_app(
588
714
  )
589
715
 
590
716
  if selected_plot == "histogram":
591
- fig = px.histogram(
717
+ fig = _histogram_figure(
592
718
  plot_df,
593
- x=x_column,
594
- color="__plot_group" if group_columns else None,
595
- nbins=int(input.bins()),
596
- barmode="overlay",
597
- 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()),
598
726
  )
599
727
  elif selected_plot == "box":
600
728
  fig = px.box(
File without changes
File without changes
File without changes