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.
- {apmtools-0.2.8 → apmtools-0.2.10}/PKG-INFO +1 -1
- {apmtools-0.2.8 → apmtools-0.2.10}/pyproject.toml +1 -1
- {apmtools-0.2.8 → apmtools-0.2.10}/src/apmtools/shiny/summary.py +250 -34
- {apmtools-0.2.8 → apmtools-0.2.10}/.gitignore +0 -0
- {apmtools-0.2.8 → apmtools-0.2.10}/LICENSE +0 -0
- {apmtools-0.2.8 → apmtools-0.2.10}/README.md +0 -0
- {apmtools-0.2.8 → apmtools-0.2.10}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.8 → apmtools-0.2.10}/src/apmtools/classes.py +0 -0
- {apmtools-0.2.8 → apmtools-0.2.10}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.8 → apmtools-0.2.10}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.8 → apmtools-0.2.10}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.8 → apmtools-0.2.10}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.8 → apmtools-0.2.10}/src/apmtools/units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: apmtools
|
|
3
|
-
Version: 0.2.
|
|
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>
|
|
@@ -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,
|
|
87
|
-
"""Apply Summary.subset() and
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 =
|
|
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
|
-
"
|
|
221
|
-
|
|
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
|
-
@
|
|
250
|
-
def
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|