apmtools 0.2.18__tar.gz → 0.2.20__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.020
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.020"
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(
@@ -374,10 +500,13 @@ def create_summary_app(
374
500
  "Summary.group_stat()."
375
501
  ),
376
502
  ),
377
- ui.card(
378
- ui.card_header("Interactive plot"),
379
- output_widget("plot", height="75vh"),
380
- ),
503
+ ui.div(
504
+ output_widget("plot", height="100%", width="100%"),
505
+ style=(
506
+ "width: 100%; max-width: 100%; aspect-ratio: 16 / 9; "
507
+ "min-height: 0; margin: 0; padding: 0;"
508
+ ),
509
+ ),
381
510
  )
382
511
 
383
512
  def server(input, output, session):
@@ -588,13 +717,15 @@ def create_summary_app(
588
717
  )
589
718
 
590
719
  if selected_plot == "histogram":
591
- fig = px.histogram(
720
+ fig = _histogram_figure(
592
721
  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,
722
+ x_column,
723
+ group_column="__plot_group" if group_columns else None,
724
+ bins=int(input.bins()),
725
+ density=input.hist_density() == "True",
726
+ stacked=input.hist_stacked() == "True",
727
+ histtype=input.hist_type(),
728
+ cumulative=int(input.hist_cumulative()),
598
729
  )
599
730
  elif selected_plot == "box":
600
731
  fig = px.box(
@@ -728,6 +859,7 @@ def create_summary_app(
728
859
  title=input.title(),
729
860
  hovermode="x unified" if selected_plot == "line" else "closest",
730
861
  margin=dict(l=60, r=30, t=70, b=60),
862
+ autosize=True,
731
863
  )
732
864
  return fig
733
865
 
File without changes
File without changes
File without changes