apmtools 0.2.8__tar.gz → 0.2.10__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.008
3
+ Version: 0.2.010
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.008"
7
+ version = "0.2.010"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -83,15 +83,22 @@ def _subset_choices(data: pd.DataFrame, column: str) -> tuple[dict[str, str], di
83
83
  return choices, value_map
84
84
 
85
85
 
86
- def _apply_filters(data, subset_column, subset_values, datetime_start, datetime_end):
87
- """Apply Summary.subset() and Summary.dtfilter() in that order."""
86
+ def _apply_filters(data, subset_filters, datetime_start=None, datetime_end=None):
87
+ """Apply multiple ``Summary.subset()`` filters and datetime filtering.
88
+
89
+ Parameters
90
+ ----------
91
+ subset_filters : dict
92
+ Mapping of column names to the original values that should be retained.
93
+ Conditions for different columns are combined with ``filter_style="all"``.
94
+ Values within one column are OR'd by :meth:`Summary.subset`.
95
+ """
88
96
  filtered = data
89
97
 
90
- if subset_column != "None" and subset_values:
91
- choices, value_map = _subset_choices(filtered, subset_column)
92
- values = [value_map[key] for key in subset_values if key in value_map]
93
- if values:
94
- filtered = filtered.subset({subset_column: values})
98
+ if subset_filters:
99
+ # Summary.subset() handles non-contiguous DataFrame indices
100
+ # positionally, so no index manipulation is required here.
101
+ filtered = filtered.subset(subset_filters, filter_style="all")
95
102
 
96
103
  if datetime_start or datetime_end:
97
104
  filtered = filtered.dtfilter(
@@ -135,6 +142,91 @@ def _aggregated_value_columns(data, value_columns, aggregate):
135
142
  return columns
136
143
 
137
144
 
145
+ def _frequency_timedelta(frequency: str) -> pd.Timedelta | None:
146
+ """Convert a timestamp aggregation frequency to its expected spacing."""
147
+ if not frequency or frequency == "weekday":
148
+ return None
149
+
150
+ parts = frequency.split()
151
+ if len(parts) == 1:
152
+ multiplier = 1.0
153
+ unit = parts[0]
154
+ elif len(parts) == 2:
155
+ try:
156
+ multiplier = float(parts[0])
157
+ except ValueError:
158
+ return None
159
+ unit = parts[1]
160
+ else:
161
+ return None
162
+
163
+ units = {
164
+ "second": "s",
165
+ "minute": "min",
166
+ "hour": "h",
167
+ "day": "d",
168
+ "week": "W",
169
+ }
170
+ if unit not in units:
171
+ return None
172
+ return pd.to_timedelta(multiplier, unit=units[unit])
173
+
174
+
175
+ def _add_gap_sections(
176
+ data: pd.DataFrame,
177
+ *,
178
+ x_column: str,
179
+ group_columns: list[str],
180
+ frequency: str,
181
+ section_column: str = "__line_section",
182
+ ) -> pd.DataFrame:
183
+ """Assign separate line sections when aggregated observations have gaps.
184
+
185
+ Rows remain in the same section while adjacent timestamps are no farther
186
+ apart than the selected aggregation frequency. Section numbering restarts
187
+ independently inside every selected plotting group.
188
+
189
+ This is primarily used when ``identifier`` was not included in
190
+ ``group_stat(grouping_by=...)`` and is therefore no longer available after
191
+ aggregation.
192
+ """
193
+ result = data.copy()
194
+ if result.empty:
195
+ result[section_column] = pd.Series(dtype="string")
196
+ return result
197
+
198
+ step = _frequency_timedelta(frequency)
199
+ if step is None or x_column not in result.columns:
200
+ # Frequencies such as weekday do not preserve enough chronological
201
+ # information to infer discontinuities reliably.
202
+ result[section_column] = "0"
203
+ return result
204
+
205
+ result[x_column] = pd.to_datetime(result[x_column], errors="coerce")
206
+ sort_columns = [*group_columns, x_column]
207
+ result = result.sort_values(sort_columns, kind="stable").copy()
208
+
209
+ if group_columns:
210
+ gap = result.groupby(group_columns, dropna=False)[x_column].diff()
211
+ new_section = gap.gt(step) | gap.isna()
212
+ section_number = (
213
+ new_section.astype(int)
214
+ .groupby([result[c] for c in group_columns], dropna=False)
215
+ .cumsum()
216
+ .sub(1)
217
+ )
218
+ group_label = result[group_columns].astype(str).agg(" | ".join, axis=1)
219
+ result[section_column] = group_label + \
220
+ " | section " + section_number.astype(str)
221
+ else:
222
+ gap = result[x_column].diff()
223
+ section_number = (gap.gt(step) | gap.isna()
224
+ ).astype(int).cumsum().sub(1)
225
+ result[section_column] = "section " + section_number.astype(str)
226
+
227
+ return result
228
+
229
+
138
230
  def create_summary_app(
139
231
  data: pd.DataFrame,
140
232
  *,
@@ -182,7 +274,7 @@ def create_summary_app(
182
274
  raise ValueError(
183
275
  f"Unknown plot_type {plot_type!r}. Choose one of: {', '.join(PLOT_TYPES)}")
184
276
 
185
- subset_columns = ["None", *columns]
277
+ subset_columns = columns
186
278
 
187
279
  app_ui = ui.page_sidebar(
188
280
  ui.sidebar(
@@ -214,12 +306,12 @@ def create_summary_app(
214
306
  ),
215
307
  ui.hr(),
216
308
  ui.h5("Subset"),
217
- ui.input_select("subset_column", "Column",
218
- choices=subset_columns, selected="None"),
219
309
  ui.input_selectize(
220
- "subset_values", "Values", choices={}, selected=[], multiple=True,
221
- options={"placeholder": "Select values"},
310
+ "subset_columns", "Columns", choices={c: c for c in subset_columns},
311
+ selected=[], multiple=True,
312
+ options={"placeholder": "Select one or more columns"},
222
313
  ),
314
+ ui.output_ui("subset_filters"),
223
315
  ui.hr(),
224
316
  ui.h5("Datetime filter"),
225
317
  ui.input_text(
@@ -246,29 +338,59 @@ def create_summary_app(
246
338
  )
247
339
 
248
340
  def server(input, output, session):
249
- @reactive.effect
250
- def _update_subset_values():
251
- selected_column = input.subset_column()
252
- if selected_column == "None":
253
- choices = {}
254
- else:
341
+ @render.ui
342
+ def subset_filters():
343
+ """Render one value selector for every selected subset column."""
344
+ selected_columns = list(input.subset_columns())
345
+ if not selected_columns:
346
+ return ui.help_text(
347
+ "Select one or more columns above to choose their values."
348
+ )
349
+
350
+ controls = []
351
+ for i, selected_column in enumerate(selected_columns):
352
+ if selected_column not in column_map:
353
+ continue
255
354
  choices, _ = _subset_choices(
256
355
  summary, column_map[selected_column])
257
- ui.update_selectize("subset_values", choices=choices, selected=[])
356
+ input_id = f"subset_values_{i}"
357
+ controls.append(
358
+ ui.input_selectize(
359
+ input_id,
360
+ f"{selected_column} values",
361
+ choices=choices,
362
+ selected=[],
363
+ multiple=True,
364
+ options={
365
+ "placeholder": f"Select {selected_column} values"},
366
+ )
367
+ )
368
+ return ui.div(*controls)
258
369
 
259
370
  @reactive.calc
260
371
  def filtered_summary():
261
- subset_column = input.subset_column()
262
- subset_values = list(input.subset_values())
372
+ selected_columns = list(input.subset_columns())
373
+ subset_filters = {}
374
+
375
+ for i, selected_column in enumerate(selected_columns):
376
+ if selected_column not in column_map:
377
+ continue
378
+ selected_keys = list(input[f"subset_values_{i}"]())
379
+ if not selected_keys:
380
+ # An explicitly selected column with no selected values
381
+ # should not remove all data; it simply contributes no
382
+ # filtering condition until values are chosen.
383
+ continue
384
+ _, value_map = _subset_choices(
385
+ summary, column_map[selected_column])
386
+ values = [value_map[key]
387
+ for key in selected_keys if key in value_map]
388
+ if values:
389
+ subset_filters[column_map[selected_column]] = values
390
+
263
391
  start = input.datetime_start().strip()
264
392
  end = input.datetime_end().strip()
265
- return _apply_filters(
266
- summary,
267
- column_map[subset_column] if subset_column != "None" else "None",
268
- subset_values,
269
- start,
270
- end,
271
- )
393
+ return _apply_filters(summary, subset_filters, start, end)
272
394
 
273
395
  @reactive.calc
274
396
  def selected_data():
@@ -415,17 +537,111 @@ def create_summary_app(
415
537
  facet_row="variable" if len(y_columns) > 1 else None, barmode="group",
416
538
  )
417
539
  else:
418
- if not group_columns:
419
- fig = px.line(plot_df, x=x_column, y=y_columns)
420
- else:
540
+ # ``identifier`` identifies an individual input monitor. It
541
+ # must therefore be used as Plotly's line-group key even when
542
+ # it is not one of the user-selected colour/group columns.
543
+ # This prevents observations from two different monitors
544
+ # being connected by a line merely because they share the
545
+ # same selected group. The identifier is deliberately NOT
546
+ # used for colour, so monitors in the same selected group
547
+ # retain the same colour. After frequency aggregation,
548
+ # identifier is only available if it was selected as a
549
+ # grouping variable. If it is absent, discontinuous runs are
550
+ # inferred from gaps larger than the selected frequency.
551
+ identifier_column = (
552
+ column_map["identifier"]
553
+ if (
554
+ "identifier" in column_map
555
+ and column_map["identifier"] in plot_df.columns
556
+ )
557
+ else None
558
+ )
559
+
560
+ section_column = None
561
+ if frequency is not None and identifier_column is None:
562
+ plot_df = _add_gap_sections(
563
+ plot_df,
564
+ x_column=x_column,
565
+ group_columns=group_columns,
566
+ frequency=frequency,
567
+ )
568
+ section_column = "__line_section"
569
+
570
+ if group_columns:
421
571
  group_column = "__plot_group"
422
- long_df = plot_df[[x_column, group_column, *y_columns]].melt(
423
- id_vars=[x_column, group_column], var_name="variable", value_name="value",
572
+ line_columns = [x_column, group_column, *y_columns]
573
+ if identifier_column is not None:
574
+ line_columns.append(identifier_column)
575
+ long_df = plot_df[line_columns].melt(
576
+ id_vars=[
577
+ x_column,
578
+ group_column,
579
+ *([identifier_column]
580
+ if identifier_column is not None else []),
581
+ ],
582
+ var_name="variable",
583
+ value_name="value",
424
584
  )
425
585
  fig = px.line(
426
- long_df, x=x_column, y="value", color=group_column,
586
+ long_df,
587
+ x=x_column,
588
+ y="value",
589
+ color=group_column,
590
+ line_group=identifier_column or section_column,
427
591
  facet_row="variable" if len(y_columns) > 1 else None,
428
592
  )
593
+ elif identifier_column is not None:
594
+ # With no explicit grouping, retain the normal Y-variable
595
+ # colouring while using identifier to keep each monitor's
596
+ # time series separate.
597
+ if len(y_columns) == 1:
598
+ fig = px.line(
599
+ plot_df,
600
+ x=x_column,
601
+ y=y_columns[0],
602
+ line_group=identifier_column,
603
+ )
604
+ else:
605
+ line_columns = [
606
+ x_column, identifier_column, *y_columns]
607
+ long_df = plot_df[line_columns].melt(
608
+ id_vars=[x_column, identifier_column],
609
+ var_name="variable",
610
+ value_name="value",
611
+ )
612
+ fig = px.line(
613
+ long_df,
614
+ x=x_column,
615
+ y="value",
616
+ color="variable",
617
+ line_group=identifier_column,
618
+ facet_row="variable",
619
+ )
620
+ elif section_column is not None:
621
+ if len(y_columns) == 1:
622
+ fig = px.line(
623
+ plot_df,
624
+ x=x_column,
625
+ y=y_columns[0],
626
+ line_group=section_column,
627
+ )
628
+ else:
629
+ line_columns = [x_column, section_column, *y_columns]
630
+ long_df = plot_df[line_columns].melt(
631
+ id_vars=[x_column, section_column],
632
+ var_name="variable",
633
+ value_name="value",
634
+ )
635
+ fig = px.line(
636
+ long_df,
637
+ x=x_column,
638
+ y="value",
639
+ color="variable",
640
+ line_group=section_column,
641
+ facet_row="variable",
642
+ )
643
+ else:
644
+ fig = px.line(plot_df, x=x_column, y=y_columns)
429
645
 
430
646
  fig.update_layout(
431
647
  title=input.title(),
File without changes
File without changes
File without changes